No repository writes. No lock.
Traverse nodes, inspect segments, diff revisions, trace history and export typed properties as JSON lines, Parquet or SQLite.
Froe is a Rust CLI and library for reading and maintaining Oak segment stores, including those used by AEM.
Inspect, export, compare revisions and maintain offline.
No running Oak instance. No JVM.
# Inspect the store and traverse content
froe summary /path/to/segmentstore
froe tree /path/to/segmentstore /content --depth 2
# Find consistent revisions
froe check /path/to/segmentstorev0.12.0 for Linux, macOS and Windows; verify archives against SHA256SUMS.
Release notesThe CLI includes Parquet and SQLite export support; the core crate exposes the repository traversal API.
Quick start in the READMEgit clone https://github.com/koraytaylan/froe.git
cd froe
cargo build --release
./target/release/froe --helpExport nodes and typed properties, then query locally in DuckDB or SQLite; repeat Parquet exports decode changed subtrees.
SQL examplesUse check to inspect consistency, then recover-journal to reconstruct the journal from surviving segments with Oak stopped.
Read index definitions and check their indexed state; dump Lucene data or import and reindex offline, subject to supported features.
Index guide · import/reindex betaExport the whole tree once, then query the files; these examples read exported data and do not modify the repository.
# Choose the format for your SQL client
froe export /path/to/segmentstore --format parquet --output ./export
froe export /path/to/segmentstore --format sqlite --output ./export.dbCount cq:Page nodes under /content, grouped by the first path component beneath it.
Link to exampleduckdb -c "
SELECT
regexp_extract(path, '^(/content/[^/]+)', 1) AS site,
count(*) AS pages,
rank() OVER (ORDER BY count(*) DESC) AS rank
FROM './export/nodes.parquet'
WHERE primary_type = 'cq:Page'
AND path LIKE '/content/%'
GROUP BY 1
ORDER BY pages DESC;"
sqlite3 ./export.db "
SELECT
substr(path, 1, 9 + instr(substr(path || '/', 10), '/') - 1) AS site,
count(*) AS pages,
rank() OVER (ORDER BY count(*) DESC) AS rank
FROM node_paths
WHERE primary_type = 'cq:Page'
AND path LIKE '/content/%'
GROUP BY 1
ORDER BY pages DESC;"
List pages whose content contains a component with the given sling:resourceType; replace mysite/components/teaser with yours.
Link to exampleduckdb -c "
SELECT DISTINCT n.path AS page
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON n.path = split_part(p.path, '/jcr:content/', 1)
WHERE p.name = 'sling:resourceType'
AND p.value = 'mysite/components/teaser'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
sqlite3 ./export.db "
SELECT DISTINCT n.path AS page
FROM properties_expanded p
JOIN node_paths n
ON n.path = substr(p.path, 1, instr(p.path, '/jcr:content/') - 1)
WHERE p.name = 'sling:resourceType'
AND p.value = 'mysite/components/teaser'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
List pages whose jcr:content names the given cq:template; editable templates live under /conf, static ones under /apps.
Link to exampleduckdb -c "
SELECT n.path AS page
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:template'
AND p.value = '/conf/mysite/settings/wcm/templates/article-page'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
sqlite3 ./export.db "
SELECT n.path AS page
FROM properties_expanded p
JOIN node_paths n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:template'
AND p.value = '/conf/mysite/settings/wcm/templates/article-page'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
Count pages per cq:template; templates no page uses do not appear.
Link to exampleduckdb -c "
SELECT p.value AS template, count(*) AS pages
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:template'
AND n.primary_type = 'cq:Page'
GROUP BY 1
ORDER BY pages DESC, 1;"
sqlite3 ./export.db "
SELECT p.value AS template, count(*) AS pages
FROM properties_expanded p
JOIN node_paths n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:template'
AND n.primary_type = 'cq:Page'
GROUP BY 1
ORDER BY pages DESC, 1;"
cq:tags is multivalued, so each tag is its own row; match the form your repository stores, a tag ID or a /content/cq:tags path.
Link to exampleduckdb -c "
SELECT n.path AS page
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:tags'
AND p.value = 'mysite:topics/news'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
sqlite3 ./export.db "
SELECT n.path AS page
FROM properties_expanded p
JOIN node_paths n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:tags'
AND p.value = 'mysite:topics/news'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
Find pages with a property whose value is exactly the asset path, such as fileReference; links inside rich text are not matched.
Link to exampleduckdb -c "
SELECT DISTINCT n.path AS page, p.name AS property
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON n.path = split_part(p.path || '/', '/jcr:content/', 1)
WHERE p.value = '/content/dam/mysite/logo.svg'
AND n.primary_type = 'cq:Page'
ORDER BY 1, 2;"
sqlite3 ./export.db "
SELECT DISTINCT n.path AS page, p.name AS property
FROM properties_expanded p
JOIN node_paths n
ON n.path = substr(p.path, 1, instr(p.path || '/', '/jcr:content/') - 1)
WHERE p.value = '/content/dam/mysite/logo.svg'
AND n.primary_type = 'cq:Page'
ORDER BY 1, 2;"
Map each fragmentVariationPath placed in page content to its page; fragments in editable template structure live under /conf and are not listed.
Link to exampleduckdb -c "
SELECT p.value AS fragment, n.path AS page
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON n.path = split_part(p.path, '/jcr:content/', 1)
WHERE p.name = 'fragmentVariationPath'
AND n.primary_type = 'cq:Page'
ORDER BY 1, 2;"
sqlite3 ./export.db "
SELECT p.value AS fragment, n.path AS page
FROM properties_expanded p
JOIN node_paths n
ON n.path = substr(p.path, 1, instr(p.path, '/jcr:content/') - 1)
WHERE p.name = 'fragmentVariationPath'
AND n.primary_type = 'cq:Page'
ORDER BY 1, 2;"
Compare cq:lastModified as a timestamp, so time zone offsets are honored; pages never edited have no cq:lastModified and are not listed.
Link to exampleduckdb -c "
SELECT n.path AS page, p.value AS last_modified
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:lastModified'
AND p.property_type = 'Date'
AND n.primary_type = 'cq:Page'
AND CAST(p.value AS TIMESTAMPTZ) < TIMESTAMPTZ '2025-01-01 00:00:00+00'
ORDER BY CAST(p.value AS TIMESTAMPTZ);"
sqlite3 ./export.db "
SELECT n.path AS page, p.value AS last_modified
FROM properties_expanded p
JOIN node_paths n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:lastModified'
AND p.property_type = 'Date'
AND n.primary_type = 'cq:Page'
AND julianday(p.value) < julianday('2025-01-01')
ORDER BY julianday(p.value);"
Group pages by cq:lastModifiedBy; only the most recent editor is recorded on the page, earlier ones live in version history.
Link to exampleduckdb -c "
SELECT p.value AS editor, count(*) AS pages
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:lastModifiedBy'
AND n.primary_type = 'cq:Page'
GROUP BY 1
ORDER BY pages DESC, 1;"
sqlite3 ./export.db "
SELECT p.value AS editor, count(*) AS pages
FROM properties_expanded p
JOIN node_paths n
ON p.path = n.path || '/jcr:content'
WHERE p.name = 'cq:lastModifiedBy'
AND n.primary_type = 'cq:Page'
GROUP BY 1
ORDER BY pages DESC, 1;"
Find site pages with no non-empty jcr:description, the page property most page components render as the meta description.
Link to exampleduckdb -c "
SELECT n.path AS page
FROM './export/nodes.parquet' n
WHERE n.primary_type = 'cq:Page'
AND n.path LIKE '/content/%'
AND n.path NOT LIKE '/content/experience-fragments/%'
AND NOT EXISTS (
SELECT 1 FROM './export/properties.parquet' p
WHERE p.path = n.path || '/jcr:content'
AND p.name = 'jcr:description'
AND p.value <> ''
)
ORDER BY 1;"
sqlite3 ./export.db "
SELECT n.path AS page
FROM node_paths n
WHERE n.primary_type = 'cq:Page'
AND n.path LIKE '/content/%'
AND n.path NOT LIKE '/content/experience-fragments/%'
AND NOT EXISTS (
SELECT 1 FROM properties_expanded p
WHERE p.path = n.path || '/jcr:content'
AND p.name = 'jcr:description'
AND p.value <> ''
)
ORDER BY 1;"
List cq:redirectTarget values and whether the target is in the export; external URLs and partial exports report false.
Link to exampleduckdb -c "
SELECT
n.path AS page,
p.value AS target,
t.path IS NOT NULL AS target_in_export
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' n
ON p.path = n.path || '/jcr:content'
LEFT JOIN './export/nodes.parquet' t
ON t.path = p.value
WHERE p.name = 'cq:redirectTarget'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
sqlite3 ./export.db "
SELECT
n.path AS page,
p.value AS target,
t.path IS NOT NULL AS target_in_export
FROM properties_expanded p
JOIN node_paths n
ON p.path = n.path || '/jcr:content'
LEFT JOIN node_paths t
ON t.path = p.value
WHERE p.name = 'cq:redirectTarget'
AND n.primary_type = 'cq:Page'
ORDER BY 1;"
Inventory index type, async lane and reindex flag; froe index list provides the dedicated store-level view.
Link to exampleduckdb -c "
SELECT
n.path,
max(CASE WHEN p.name = 'type' THEN p.value END) AS index_type,
group_concat(DISTINCT CASE WHEN p.name = 'async' THEN p.value END) AS lane,
max(CASE WHEN p.name = 'reindex' THEN p.value END) AS reindex
FROM './export/nodes.parquet' n
JOIN './export/properties.parquet' p
ON p.path = n.path
WHERE n.primary_type = 'oak:QueryIndexDefinition'
GROUP BY 1
ORDER BY 1;"
sqlite3 ./export.db "
SELECT
n.path,
max(CASE WHEN p.name = 'type' THEN p.value END) AS index_type,
group_concat(DISTINCT CASE WHEN p.name = 'async' THEN p.value END) AS lane,
max(CASE WHEN p.name = 'reindex' THEN p.value END) AS reindex
FROM node_paths n
JOIN properties_expanded p
ON p.path = n.path
WHERE n.primary_type = 'oak:QueryIndexDefinition'
GROUP BY 1
ORDER BY 1;"
Candidates with no external property value naming the asset or a descendant; UUID, embedded and external references are not covered, so this is not a deletion list.
Link to exampleduckdb -c "
SELECT a.path
FROM './export/nodes.parquet' a
WHERE a.primary_type = 'dam:Asset'
AND NOT EXISTS (
SELECT 1 FROM './export/properties.parquet' p
WHERE (p.value = a.path
OR substr(p.value, 1, length(a.path) + 1) = a.path || '/')
AND p.path <> a.path
AND substr(p.path, 1, length(a.path) + 1) <> a.path || '/'
)
ORDER BY a.path;"
sqlite3 ./export.db "
SELECT a.path
FROM node_paths a
WHERE a.primary_type = 'dam:Asset'
AND NOT EXISTS (
SELECT 1 FROM properties_expanded p
WHERE (p.value = a.path
OR substr(p.value, 1, length(a.path) + 1) = a.path || '/')
AND p.path <> a.path
AND substr(p.path, 1, length(a.path) + 1) <> a.path || '/'
)
ORDER BY a.path;"
Group assets by dc:format with the original renditions' sizes; external binaries have no recorded length, so they are counted separately.
Link to exampleduckdb -c "
SELECT
m.value AS mime_type,
count(*) AS assets,
sum(o.binary_length) AS inline_bytes,
count(o.binary_reference) AS external
FROM './export/properties.parquet' m
JOIN './export/nodes.parquet' a
ON m.path = a.path || '/jcr:content/metadata'
LEFT JOIN './export/properties.parquet' o
ON o.path = a.path || '/jcr:content/renditions/original/jcr:content'
AND o.name = 'jcr:data'
WHERE m.name = 'dc:format'
AND a.primary_type = 'dam:Asset'
GROUP BY 1
ORDER BY assets DESC, 1;"
sqlite3 ./export.db "
SELECT
m.value AS mime_type,
count(*) AS assets,
sum(o.binary_length) AS inline_bytes,
count(o.binary_reference) AS external
FROM properties_expanded m
JOIN node_paths a
ON m.path = a.path || '/jcr:content/metadata'
LEFT JOIN properties_expanded o
ON o.path = a.path || '/jcr:content/renditions/original/jcr:content'
AND o.name = 'jcr:data'
WHERE m.name = 'dc:format'
AND a.primary_type = 'dam:Asset'
GROUP BY 1
ORDER BY assets DESC, 1;"
Count content fragments per cq:model, read from each fragment's jcr:content/data node.
Link to exampleduckdb -c "
SELECT p.value AS model, count(*) AS fragments
FROM './export/properties.parquet' p
JOIN './export/nodes.parquet' a
ON p.path = a.path || '/jcr:content/data'
WHERE p.name = 'cq:model'
AND a.primary_type = 'dam:Asset'
GROUP BY 1
ORDER BY fragments DESC, 1;"
sqlite3 ./export.db "
SELECT p.value AS model, count(*) AS fragments
FROM properties_expanded p
JOIN node_paths a
ON p.path = a.path || '/jcr:content/data'
WHERE p.name = 'cq:model'
AND a.primary_type = 'dam:Asset'
GROUP BY 1
ORDER BY fragments DESC, 1;"
Rank sling:resourceType usage within each site; ties can return more than five types.
Link to exampleduckdb -c "
WITH uses AS (
SELECT
regexp_extract(path, '^(/content/[^/]+)', 1) AS site,
value AS resource_type,
count(DISTINCT path) AS uses
FROM './export/properties.parquet'
WHERE name = 'sling:resourceType'
AND path LIKE '/content/%'
GROUP BY 1, 2
)
SELECT site, resource_type, uses, rank
FROM (
SELECT
site,
resource_type,
uses,
rank() OVER (PARTITION BY site ORDER BY uses DESC) AS rank
FROM uses
)
WHERE rank <= 5
ORDER BY site, rank;"
sqlite3 ./export.db "
WITH uses AS (
SELECT
substr(path, 1, 9 + instr(substr(path || '/', 10), '/') - 1) AS site,
value AS resource_type,
count(DISTINCT path) AS uses
FROM properties_expanded
WHERE name = 'sling:resourceType'
AND path LIKE '/content/%'
GROUP BY 1, 2
)
SELECT site, resource_type, uses, rank
FROM (
SELECT
site,
resource_type,
uses,
rank() OVER (PARTITION BY site ORDER BY uses DESC) AS rank
FROM uses
)
WHERE rank <= 5
ORDER BY site, rank;"
Find repeated jcr:title values across distinct nodes; repeated titles are not necessarily errors.
Link to exampleduckdb -c "
SELECT value AS title, count(DISTINCT path) AS uses
FROM './export/properties.parquet'
WHERE name = 'jcr:title'
AND value IS NOT NULL
GROUP BY 1
HAVING count(DISTINCT path) > 1
ORDER BY uses DESC;"
sqlite3 ./export.db "
SELECT value AS title, count(DISTINCT path) AS uses
FROM properties_expanded
WHERE name = 'jcr:title'
AND value IS NOT NULL
GROUP BY 1
HAVING count(DISTINCT path) > 1
ORDER BY uses DESC;"
Find parents with more than 200 immediate children; this is a structural inventory, not a performance diagnosis.
Link to exampleduckdb -c "
SELECT parent_path, count(*) AS children
FROM './export/nodes.parquet'
WHERE parent_path IS NOT NULL
GROUP BY 1
HAVING count(*) > 200
ORDER BY children DESC;"
sqlite3 ./export.db "
SELECT p.path AS parent_path, count(*) AS children
FROM nodes c
JOIN node_paths p ON p.id = c.parent_id
GROUP BY p.id, p.path
HAVING count(*) > 200
ORDER BY children DESC;"
Find sling:resourceType values used by a single node; each result needs review before changing content.
Link to exampleduckdb -c "
SELECT value AS resource_type, count(DISTINCT path) AS uses
FROM './export/properties.parquet'
WHERE name = 'sling:resourceType'
GROUP BY 1
HAVING count(DISTINCT path) = 1
ORDER BY 1;"
sqlite3 ./export.db "
SELECT value AS resource_type, count(DISTINCT path) AS uses
FROM properties_expanded
WHERE name = 'sling:resourceType'
GROUP BY 1
HAVING count(DISTINCT path) = 1
ORDER BY 1;"
Count distinct nodes with fileReference values pointing into /content/dam; this covers that property only.
Link to exampleduckdb -c "
SELECT value AS asset, count(DISTINCT path) AS refs
FROM './export/properties.parquet'
WHERE name = 'fileReference'
AND value LIKE '/content/dam/%'
GROUP BY 1
HAVING count(DISTINCT path) >= 10
ORDER BY refs DESC;"
sqlite3 ./export.db "
SELECT value AS asset, count(DISTINCT path) AS refs
FROM properties_expanded
WHERE name = 'fileReference'
AND value LIKE '/content/dam/%'
GROUP BY 1
HAVING count(DISTINCT path) >= 10
ORDER BY refs DESC;"
Count distinct property names, not property values; multivalued properties contribute one name.
Link to exampleduckdb -c "
SELECT path, count(DISTINCT name) AS props
FROM './export/properties.parquet'
GROUP BY 1
HAVING count(DISTINCT name) > 80
ORDER BY props DESC
LIMIT 50;"
sqlite3 ./export.db "
SELECT path, count(DISTINCT name) AS props
FROM properties_expanded
GROUP BY 1
HAVING count(DISTINCT name) > 80
ORDER BY props DESC
LIMIT 50;"
Review absolute path-shaped values with no exported node; a partial export or a string that is not a repository reference can produce a match.
Link to exampleduckdb -c "
SELECT p.path, p.name, p.value
FROM './export/properties.parquet' p
WHERE p.value LIKE '/%'
AND NOT EXISTS (
SELECT 1 FROM './export/nodes.parquet' n
WHERE n.path = p.value
);"
sqlite3 ./export.db "
SELECT p.path, p.name, p.value
FROM properties_expanded p
WHERE p.value LIKE '/%'
AND NOT EXISTS (
SELECT 1 FROM node_paths n
WHERE n.path = p.value
);"
Find cq:Page nodes whose jcr:content child is absent; use a full-depth export before treating a result as missing content.
Link to exampleduckdb -c "
SELECT n.path
FROM './export/nodes.parquet' n
WHERE n.primary_type = 'cq:Page'
AND NOT EXISTS (
SELECT 1 FROM './export/nodes.parquet' c
WHERE c.path = n.path || '/jcr:content'
)
ORDER BY 1;"
sqlite3 ./export.db "
SELECT n.path
FROM node_paths n
WHERE n.primary_type = 'cq:Page'
AND NOT EXISTS (
SELECT 1 FROM node_paths c
WHERE c.path = n.path || '/jcr:content'
)
ORDER BY 1;"
Group cq:lastReplicationAction values by distinct node; historical metadata does not establish current publish state.
Link to exampleduckdb -c "
SELECT value AS action, count(DISTINCT path) AS nodes
FROM './export/properties.parquet'
WHERE name = 'cq:lastReplicationAction'
GROUP BY 1
ORDER BY 2 DESC;"
sqlite3 ./export.db "
SELECT value AS action, count(DISTINCT path) AS nodes
FROM properties_expanded
WHERE name = 'cq:lastReplicationAction'
GROUP BY 1
ORDER BY 2 DESC;"
Rank non-null binary_length values; external binaries are represented by binary_reference rather than inline bytes.
Link to exampleduckdb -c "
SELECT path, name, binary_length
FROM './export/properties.parquet'
WHERE binary_length IS NOT NULL
ORDER BY binary_length DESC
LIMIT 25;"
sqlite3 ./export.db "
SELECT path, name, binary_length
FROM properties_expanded
WHERE binary_length IS NOT NULL
ORDER BY binary_length DESC
LIMIT 25;"
Parquet stores one row per node and one row per property value; SQLite exposes node_paths and properties_expanded. Completed Parquet exports carry matching revision stamps, but a query during file replacement can observe a mixed pair; see the export consistency contract.
Reads store.version=1 and 2; maintenance targets version 2, with conditional upgrades for version 1 cleanup.
Write-path interoperability is tested with Oak 1.90.0 in Apache Sling; AEM itself and external blob stores remain unverified.
Lucene index import and reindex are beta in v0.12.0; unsupported rebuild features are refused rather than approximated.
Interoperability test contract