added mermaid and a lot of cmap changes

This commit is contained in:
2026-08-27 13:13:39 +02:00
parent 20c1584016
commit 2215d1d04a
35 changed files with 6442 additions and 350 deletions
+814 -1
View File
@@ -9,6 +9,7 @@
racket/path
racket/string
"attachment-references.rkt"
"concept-id.rkt"
"config.rkt"
"todo.rkt")
@@ -20,7 +21,7 @@
;; Supporting functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define current-schema-version 12)
(define current-schema-version 21)
(define schema-1-statements
(list
@@ -421,6 +422,791 @@ SQL
)
(record-schema-version! db 12))
(define (migrate-12->13! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS people (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
SQL
)
(query-exec db "CREATE UNIQUE INDEX IF NOT EXISTS people_name_unique_idx ON people(lower(name))")
(query-exec
db
#<<SQL
WITH person_names AS (
SELECT DISTINCT trim(tag ->> 'value') AS name
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(concept -> 'tags') = 'array'
THEN concept -> 'tags' ELSE '[]'::jsonb END
) AS tag
WHERE tag ->> 'type' = 'person' AND trim(coalesce(tag ->> 'value', '')) <> ''
)
INSERT INTO people(name, active, created_at, updated_at)
SELECT name, TRUE, $1, $1 FROM person_names
ON CONFLICT (lower(name)) DO NOTHING
SQL
(current-seconds))
(record-schema-version! db 13))
(define (migrate-13->14! db)
;; Concept identity is shared between CMaps. Older generated numeric ids were
;; only unique inside one map, so namespace those before building the global
;; repository.
(query-exec
db
#<<SQL
UPDATE concept_maps cm
SET document = cm.document || jsonb_build_object(
'concepts', CASE
WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN
(SELECT coalesce(jsonb_agg(
CASE WHEN concept ->> 'id' ~ '^concept-[0-9]+$'
THEN jsonb_set(concept, '{id}', to_jsonb(concat('legacy:', cm.slug, ':', concept ->> 'id')))
ELSE concept END
), '[]'::jsonb)
FROM jsonb_array_elements(cm.document -> 'concepts') AS concept)
ELSE coalesce(cm.document -> 'concepts', '[]'::jsonb)
END,
'items', CASE
WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN
(SELECT coalesce(jsonb_agg(
CASE WHEN item ->> 'conceptId' ~ '^concept-[0-9]+$'
THEN jsonb_set(item, '{conceptId}', to_jsonb(concat('legacy:', cm.slug, ':', item ->> 'conceptId')))
ELSE item END
), '[]'::jsonb)
FROM jsonb_array_elements(cm.document -> 'items') AS item)
ELSE coalesce(cm.document -> 'items', '[]'::jsonb)
END
)
WHERE jsonb_typeof(cm.document) = 'object'
SQL
)
(query-exec
db
#<<SQL
CREATE TABLE IF NOT EXISTS concept_definitions (
id TEXT PRIMARY KEY,
document JSONB NOT NULL,
updated_at BIGINT NOT NULL,
updated_by TEXT NOT NULL
)
SQL
)
;; If a shared concept already has diverging copies, the copy from the most
;; recently edited active map becomes the initial canonical definition.
(query-exec
db
#<<SQL
WITH candidates AS (
SELECT concept ->> 'id' AS id,
concept AS document,
cm.updated_at,
cm.updated_by,
row_number() OVER (
PARTITION BY concept ->> 'id'
ORDER BY cm.updated_at DESC, cm.id DESC
) AS position
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
WHERE cm.archived = FALSE AND nullif(concept ->> 'id', '') IS NOT NULL
)
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
SELECT id, document, updated_at, updated_by
FROM candidates
WHERE position = 1
ON CONFLICT (id) DO NOTHING
SQL
)
(record-schema-version! db 14))
(define (migrate-14->15! db)
;; Schema 14 originally filled the shared repository only from the concepts
;; array. Sub-CMap heads are placements too, but older documents often keep
;; their content solely on the item. Import those missing definitions.
(query-exec
db
#<<SQL
WITH candidates AS (
SELECT item ->> 'conceptId' AS id,
jsonb_strip_nulls(jsonb_build_object(
'id', item -> 'conceptId',
'label', coalesce(repository.document, item) -> 'label',
'synopsis', coalesce(repository.document, item) -> 'synopsis',
'aspects', coalesce(repository.document, item) -> 'aspects',
'tags', coalesce(repository.document, item) -> 'tags',
'descriptionPageSlug', coalesce(repository.document, item) -> 'descriptionPageSlug',
'pageSlug', coalesce(repository.document, item) -> 'pageSlug',
'cmapSlug', coalesce(repository.document, item) -> 'cmapSlug',
'parentCmapLink', coalesce(repository.document, item) -> 'parentCmapLink',
'imageSource', coalesce(repository.document, item) -> 'imageSource'
)) AS document,
cm.updated_at,
cm.updated_by,
row_number() OVER (
PARTITION BY item ->> 'conceptId'
ORDER BY cm.updated_at DESC, cm.id DESC
) AS position
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
LEFT JOIN LATERAL (
SELECT concept AS document
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
WHERE concept ->> 'id' = item ->> 'conceptId'
LIMIT 1
) repository ON TRUE
WHERE cm.archived = FALSE
AND nullif(item ->> 'conceptId', '') IS NOT NULL
AND coalesce(item ->> 'kind', 'concept') <> 'phrase'
)
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
SELECT id, document, updated_at, updated_by
FROM candidates
WHERE position = 1
ON CONFLICT (id) DO NOTHING
SQL
)
(record-schema-version! db 15))
(define (migrate-15->16! db)
;; A placement linked to a derived CMap and the head concept of that CMap
;; denote one concept. Retain aliases so old documents and open editors can
;; be canonicalized without making diagram structure part of the concept.
(query-exec
db
#<<SQL
CREATE TABLE IF NOT EXISTS concept_aliases (
alias_id TEXT PRIMARY KEY,
canonical_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
created_by TEXT NOT NULL,
CHECK(alias_id <> canonical_id)
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS concept_aliases_canonical_idx ON concept_aliases(canonical_id)")
(query-exec
db
#<<SQL
WITH derived_roots AS (
SELECT target.slug AS target_slug,
root_item ->> 'conceptId' AS canonical_id
FROM concept_maps target
JOIN concept_maps source
ON source.slug = target.document #>> '{derivedView,sourceCmapSlug}'
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(source.document -> 'items') = 'array'
THEN source.document -> 'items' ELSE '[]'::jsonb END
) AS root_item
WHERE target.archived = FALSE
AND source.archived = FALSE
AND root_item ->> 'id' = target.document #>> '{derivedView,rootItemId}'
AND nullif(root_item ->> 'conceptId', '') IS NOT NULL
UNION ALL
SELECT target.slug AS target_slug,
root_item ->> 'conceptId' AS canonical_id
FROM concept_maps target
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(target.document -> 'items') = 'array'
THEN target.document -> 'items' ELSE '[]'::jsonb END
) AS root_item
LEFT JOIN LATERAL (
SELECT concept AS document
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(target.document -> 'concepts') = 'array'
THEN target.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
WHERE concept ->> 'id' = root_item ->> 'conceptId'
LIMIT 1
) repository ON TRUE
WHERE target.archived = FALSE
AND target.document -> 'derivedView' IS NULL
AND nullif(root_item ->> 'conceptId', '') IS NOT NULL
AND coalesce(root_item ->> 'kind', 'concept') <> 'phrase'
AND nullif(root_item ->> 'parentSubmapId', '') IS NULL
AND lower(trim(coalesce(repository.document ->> 'label', root_item ->> 'label', '')))
= lower(trim(target.title))
), linked_placements AS (
SELECT DISTINCT item ->> 'conceptId' AS alias_id,
derived_roots.canonical_id
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
JOIN derived_roots ON derived_roots.target_slug = item ->> 'cmapSlug'
WHERE cm.archived = FALSE
AND nullif(item ->> 'conceptId', '') IS NOT NULL
AND item ->> 'conceptId' <> derived_roots.canonical_id
)
INSERT INTO concept_aliases(alias_id, canonical_id, created_at, created_by)
SELECT alias_id, canonical_id, $1, 'system:concept-identity-migration'
FROM linked_placements
ON CONFLICT (alias_id) DO UPDATE
SET canonical_id = EXCLUDED.canonical_id
SQL
(current-seconds))
;; Preserve the newest available content when two ids are merged.
(query-exec
db
#<<SQL
WITH resolved AS (
SELECT coalesce(alias.canonical_id, definition.id) AS canonical_id,
definition.document,
definition.updated_at,
definition.updated_by,
row_number() OVER (
PARTITION BY coalesce(alias.canonical_id, definition.id)
ORDER BY definition.updated_at DESC, definition.id DESC
) AS position
FROM concept_definitions definition
LEFT JOIN concept_aliases alias ON alias.alias_id = definition.id
)
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
SELECT canonical_id,
jsonb_set(document - 'kind' - 'parentCmapLink', '{id}', to_jsonb(canonical_id)),
updated_at,
updated_by
FROM resolved
WHERE position = 1
ON CONFLICT (id) DO UPDATE
SET document = EXCLUDED.document,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
SQL
)
(record-schema-version! db 16))
(define (migrate-16->17! db)
;; Within one wiki a normalized concept name denotes one concept. Merge ids
;; that predate this invariant, while preserving a canonical id already
;; established through a linked CMap when one exists.
(query-exec
db
#<<SQL
WITH named AS (
SELECT definition.id,
definition.document,
definition.updated_at,
definition.updated_by,
lower(trim(definition.document ->> 'label')) AS name_key,
coalesce(existing_alias.canonical_id, definition.id) AS resolved_id
FROM concept_definitions definition
LEFT JOIN concept_aliases existing_alias
ON existing_alias.alias_id = definition.id
WHERE nullif(trim(definition.document ->> 'label'), '') IS NOT NULL
), ranked AS (
SELECT named.*,
first_value(resolved_id) OVER (
PARTITION BY name_key
ORDER BY updated_at DESC, id DESC
) AS canonical_id,
row_number() OVER (
PARTITION BY name_key
ORDER BY updated_at DESC, id DESC
) AS content_position
FROM named
), new_aliases AS (
SELECT DISTINCT id AS alias_id, canonical_id
FROM ranked
WHERE id <> canonical_id
), stored_aliases AS (
INSERT INTO concept_aliases(alias_id, canonical_id, created_at, created_by)
SELECT alias_id, canonical_id, $1, 'system:concept-name-migration'
FROM new_aliases
ON CONFLICT (alias_id) DO UPDATE
SET canonical_id = EXCLUDED.canonical_id
RETURNING alias_id
), winners AS (
SELECT DISTINCT ON (canonical_id)
canonical_id, document, updated_at, updated_by
FROM ranked
WHERE content_position = 1
ORDER BY canonical_id, updated_at DESC, id DESC
)
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
SELECT canonical_id,
jsonb_set(document - 'kind' - 'parentCmapLink', '{id}', to_jsonb(canonical_id)),
updated_at,
updated_by
FROM winners
ON CONFLICT (id) DO UPDATE
SET document = EXCLUDED.document,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
SQL
(current-seconds))
(query-exec
db
#<<SQL
UPDATE concept_maps cm
SET document = cm.document || jsonb_build_object(
'items', CASE
WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN
(SELECT coalesce(jsonb_agg(
CASE WHEN alias.canonical_id IS NOT NULL
THEN jsonb_set(item, '{conceptId}', to_jsonb(alias.canonical_id))
ELSE item END
ORDER BY ordinal
), '[]'::jsonb)
FROM jsonb_array_elements(cm.document -> 'items') WITH ORDINALITY AS entry(item, ordinal)
LEFT JOIN concept_aliases alias ON alias.alias_id = item ->> 'conceptId')
ELSE coalesce(cm.document -> 'items', '[]'::jsonb)
END,
'concepts', CASE
WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN
(SELECT coalesce(jsonb_agg(rewritten ORDER BY ordinal), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (coalesce(alias.canonical_id, concept ->> 'id'))
CASE WHEN alias.canonical_id IS NOT NULL
THEN jsonb_set(concept, '{id}', to_jsonb(alias.canonical_id))
ELSE concept END AS rewritten,
ordinal
FROM jsonb_array_elements(cm.document -> 'concepts') WITH ORDINALITY
AS entry(concept, ordinal)
LEFT JOIN concept_aliases alias ON alias.alias_id = concept ->> 'id'
ORDER BY coalesce(alias.canonical_id, concept ->> 'id'), ordinal DESC
) definitions)
ELSE coalesce(cm.document -> 'concepts', '[]'::jsonb)
END
)
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
JOIN concept_aliases alias ON alias.alias_id = item ->> 'conceptId'
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
JOIN concept_aliases alias ON alias.alias_id = concept ->> 'id'
)
SQL
)
(query-exec
db
#<<SQL
DELETE FROM concept_definitions definition
USING concept_aliases alias
WHERE definition.id = alias.alias_id
SQL
)
(record-schema-version! db 17))
(define (migrate-17->18! db)
;; Normalize the current documents themselves. Compatibility aliases are not
;; part of the final model: every active placement is rewritten to the one
;; id selected for its normalized name, then duplicate definitions are gone.
(query-exec
db
#<<SQL
CREATE TEMP TABLE concept_id_merge (
old_id TEXT PRIMARY KEY,
canonical_id TEXT NOT NULL
) ON COMMIT DROP
SQL
)
(query-exec
db
#<<SQL
WITH placements AS (
SELECT item ->> 'conceptId' AS concept_id,
lower(trim(coalesce(repository.document ->> 'label', item ->> 'label', ''))) AS name_key,
cm.updated_at,
cm.id AS map_id
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
LEFT JOIN LATERAL (
SELECT concept AS document
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
WHERE concept ->> 'id' = item ->> 'conceptId'
LIMIT 1
) repository ON TRUE
WHERE cm.archived = FALSE
AND nullif(item ->> 'conceptId', '') IS NOT NULL
AND coalesce(item ->> 'kind', 'concept') <> 'phrase'
), latest_name_per_id AS (
SELECT DISTINCT ON (concept_id)
concept_id, name_key, updated_at, map_id
FROM placements
WHERE name_key <> ''
ORDER BY concept_id, updated_at DESC, map_id DESC
), ranked AS (
SELECT concept_id,
first_value(concept_id) OVER (
PARTITION BY name_key
ORDER BY updated_at DESC, map_id DESC, concept_id DESC
) AS canonical_id
FROM latest_name_per_id
)
INSERT INTO concept_id_merge(old_id, canonical_id)
SELECT concept_id, canonical_id
FROM ranked
WHERE concept_id <> canonical_id
SQL
)
(query-exec
db
#<<SQL
UPDATE concept_maps cm
SET document = cm.document || jsonb_build_object(
'items', CASE
WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN
(SELECT coalesce(jsonb_agg(
CASE WHEN merge.canonical_id IS NOT NULL
THEN jsonb_set(item, '{conceptId}', to_jsonb(merge.canonical_id))
ELSE item END
ORDER BY ordinal
), '[]'::jsonb)
FROM jsonb_array_elements(cm.document -> 'items') WITH ORDINALITY AS entry(item, ordinal)
LEFT JOIN concept_id_merge merge ON merge.old_id = item ->> 'conceptId')
ELSE coalesce(cm.document -> 'items', '[]'::jsonb)
END,
'concepts', CASE
WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN
(SELECT coalesce(jsonb_agg(rewritten ORDER BY ordinal), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (coalesce(merge.canonical_id, concept ->> 'id'))
CASE WHEN merge.canonical_id IS NOT NULL
THEN jsonb_set(concept, '{id}', to_jsonb(merge.canonical_id))
ELSE concept END AS rewritten,
ordinal
FROM jsonb_array_elements(cm.document -> 'concepts') WITH ORDINALITY
AS entry(concept, ordinal)
LEFT JOIN concept_id_merge merge ON merge.old_id = concept ->> 'id'
ORDER BY coalesce(merge.canonical_id, concept ->> 'id'), ordinal DESC
) definitions)
ELSE coalesce(cm.document -> 'concepts', '[]'::jsonb)
END
)
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
JOIN concept_id_merge merge ON merge.old_id = item ->> 'conceptId'
)
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
JOIN concept_id_merge merge ON merge.old_id = concept ->> 'id'
)
SQL
)
(query-exec db "DELETE FROM concept_definitions")
(query-exec
db
#<<SQL
WITH candidates AS (
SELECT concept ->> 'id' AS id,
concept - 'kind' - 'parentCmapLink' AS document,
cm.updated_at,
cm.updated_by,
cm.id AS map_id
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
WHERE cm.archived = FALSE AND nullif(concept ->> 'id', '') IS NOT NULL
UNION ALL
SELECT item ->> 'conceptId' AS id,
jsonb_strip_nulls(jsonb_build_object(
'id', item -> 'conceptId',
'label', item -> 'label',
'synopsis', item -> 'synopsis',
'aspects', item -> 'aspects',
'tags', item -> 'tags',
'descriptionPageSlug', item -> 'descriptionPageSlug',
'pageSlug', item -> 'pageSlug',
'cmapSlug', item -> 'cmapSlug',
'imageSource', item -> 'imageSource'
)) AS document,
cm.updated_at,
cm.updated_by,
cm.id AS map_id
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
WHERE cm.archived = FALSE
AND nullif(item ->> 'conceptId', '') IS NOT NULL
AND coalesce(item ->> 'kind', 'concept') <> 'phrase'
), ranked AS (
SELECT candidates.*,
row_number() OVER (
PARTITION BY id
ORDER BY updated_at DESC, map_id DESC
) AS position
FROM candidates
)
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
SELECT id, jsonb_set(document, '{id}', to_jsonb(id)), updated_at, updated_by
FROM ranked
WHERE position = 1
SQL
)
(query-exec db "DROP TABLE IF EXISTS concept_aliases")
(record-schema-version! db 18))
(define (migrate-18->19! db)
;; Concept content must have exactly one source. Remove the old denormalized
;; copies from placements; linking phrases retain their own text because
;; they are relations rather than concepts.
(query-exec
db
#<<SQL
UPDATE concept_maps cm
SET document = jsonb_set(
cm.document,
'{items}',
(SELECT coalesce(jsonb_agg(
CASE WHEN coalesce(item ->> 'kind', 'concept') = 'phrase'
THEN item
ELSE item
- 'label'
- 'synopsis'
- 'aspects'
- 'tags'
- 'descriptionPageSlug'
- 'pageSlug'
- 'cmapSlug'
- 'imageSource'
END
ORDER BY ordinal
), '[]'::jsonb)
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) WITH ORDINALITY AS entry(item, ordinal)),
TRUE
)
WHERE jsonb_typeof(cm.document) = 'object'
SQL
)
(record-schema-version! db 19))
(define (migrate-19->20! db)
;; Current CMaps store concept references only. Full concept content lives in
;; concept_definitions and is hydrated into API responses on read. Historical
;; snapshots remain immutable.
(query-exec
db
#<<SQL
UPDATE concept_maps cm
SET document = cm.document || jsonb_build_object(
'items',
(SELECT coalesce(jsonb_agg(
CASE WHEN coalesce(item ->> 'kind', 'concept') = 'phrase'
THEN item
ELSE item
- 'label'
- 'synopsis'
- 'aspects'
- 'tags'
- 'descriptionPageSlug'
- 'pageSlug'
- 'cmapSlug'
- 'imageSource'
END
ORDER BY ordinal
), '[]'::jsonb)
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) WITH ORDINALITY AS entry(item, ordinal)),
'concepts',
(SELECT coalesce(jsonb_agg(jsonb_build_object('id', concept_id)
ORDER BY ordinal), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (concept_id) concept_id, ordinal
FROM (
SELECT concept ->> 'id' AS concept_id, ordinal
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) WITH ORDINALITY AS concept_entry(concept, ordinal)
UNION ALL
SELECT item ->> 'conceptId' AS concept_id, 1000000000 + ordinal
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) WITH ORDINALITY AS item_entry(item, ordinal)
WHERE coalesce(item ->> 'kind', 'concept') <> 'phrase'
) all_references
WHERE nullif(concept_id, '') IS NOT NULL
ORDER BY concept_id, ordinal
) concept_references)
)
WHERE jsonb_typeof(cm.document) = 'object'
SQL
)
(record-schema-version! db 20))
(define (migrate-20->21! db)
;; Current concept identity is a plain globally unique UUID. Preserve the
;; UUID portion of newer concept-<uuid> identifiers and allocate a UUID only
;; for genuinely old/local ids. CMap slugs and historical snapshots are not
;; identity aliases and deliberately remain unchanged.
(query-exec
db
#<<SQL
CREATE TEMP TABLE concept_uuid_rekey (
old_id TEXT PRIMARY KEY,
new_id TEXT NOT NULL
) ON COMMIT DROP
SQL
)
(define old-ids
(query-list
db
#<<SQL
WITH all_current_ids AS (
SELECT id AS old_id FROM concept_definitions
UNION
SELECT concept ->> 'id' AS old_id
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) AS concept
UNION
SELECT item ->> 'conceptId' AS old_id
FROM concept_maps cm
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) AS item
WHERE coalesce(item ->> 'kind', 'concept') <> 'phrase'
)
SELECT old_id
FROM all_current_ids
WHERE nullif(old_id, '') IS NOT NULL
ORDER BY old_id
SQL
))
;; Reserve every already canonical UUID before generating replacements, so a
;; random id can never collide with a UUID encountered later in the query.
(define used-ids (make-hash))
(for ([old-id (in-list old-ids)])
(define normalized (normalize-concept-id old-id))
(when normalized (hash-set! used-ids normalized #t)))
(define (fresh-unused-id)
(let loop ()
(define candidate (new-concept-id))
(if (hash-has-key? used-ids candidate)
(loop)
(begin
(hash-set! used-ids candidate #t)
candidate))))
(for ([old-id (in-list old-ids)])
(query-exec
db
"INSERT INTO concept_uuid_rekey(old_id, new_id) VALUES ($1, $2)"
old-id
(or (normalize-concept-id old-id) (fresh-unused-id))))
(query-exec
db
#<<SQL
UPDATE concept_maps cm
SET document = cm.document || jsonb_build_object(
'items',
(SELECT coalesce(jsonb_agg(
CASE WHEN rekey.new_id IS NULL
THEN item
ELSE jsonb_set(item, '{conceptId}', to_jsonb(rekey.new_id))
END
ORDER BY ordinal
), '[]'::jsonb)
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array'
THEN cm.document -> 'items' ELSE '[]'::jsonb END
) WITH ORDINALITY AS entry(item, ordinal)
LEFT JOIN concept_uuid_rekey rekey ON rekey.old_id = item ->> 'conceptId'),
'concepts',
(SELECT coalesce(jsonb_agg(jsonb_build_object('id', new_id)
ORDER BY ordinal), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (rekey.new_id) rekey.new_id, ordinal
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array'
THEN cm.document -> 'concepts' ELSE '[]'::jsonb END
) WITH ORDINALITY AS entry(concept, ordinal)
JOIN concept_uuid_rekey rekey ON rekey.old_id = concept ->> 'id'
ORDER BY rekey.new_id, ordinal
) rewritten_concepts)
)
WHERE jsonb_typeof(cm.document) = 'object'
SQL
)
(query-exec
db
#<<SQL
CREATE TEMP TABLE rekeyed_concept_definitions ON COMMIT DROP AS
SELECT DISTINCT ON (rekey.new_id)
rekey.new_id AS id,
jsonb_set(definition.document, '{id}', to_jsonb(rekey.new_id)) AS document,
definition.updated_at,
definition.updated_by
FROM concept_definitions definition
JOIN concept_uuid_rekey rekey ON rekey.old_id = definition.id
ORDER BY rekey.new_id, definition.updated_at DESC, definition.id
SQL
)
(query-exec db "DELETE FROM concept_definitions")
(query-exec
db
#<<SQL
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
SELECT id, document, updated_at, updated_by
FROM rekeyed_concept_definitions
SQL
)
(query-exec
db
#<<SQL
ALTER TABLE concept_definitions
ADD CONSTRAINT concept_definitions_uuid_id_check
CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
SQL
)
(record-schema-version! db 21))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
; pre : db is a writable PostgreSQL connection and config identifies the
@@ -468,6 +1254,33 @@ SQL
(define after-concept-map-history (database-schema-version db))
(when (= after-concept-map-history 11)
(migrate-11->12! db))
(define after-concept-map-history-cleanup (database-schema-version db))
(when (= after-concept-map-history-cleanup 12)
(migrate-12->13! db))
(define after-people (database-schema-version db))
(when (= after-people 13)
(migrate-13->14! db))
(define after-concept-definitions (database-schema-version db))
(when (= after-concept-definitions 14)
(migrate-14->15! db))
(define after-submap-concepts (database-schema-version db))
(when (= after-submap-concepts 15)
(migrate-15->16! db))
(define after-concept-link-aliases (database-schema-version db))
(when (= after-concept-link-aliases 16)
(migrate-16->17! db))
(define after-concept-name-merge (database-schema-version db))
(when (= after-concept-name-merge 17)
(migrate-17->18! db))
(define after-concept-normalization (database-schema-version db))
(when (= after-concept-normalization 18)
(migrate-18->19! db))
(define after-placement-content-cleanup (database-schema-version db))
(when (= after-placement-content-cleanup 19)
(migrate-19->20! db))
(define after-central-concept-references (database-schema-version db))
(when (= after-central-concept-references 20)
(migrate-20->21! db))
(define resulting-version (database-schema-version db))
(when (> resulting-version current-schema-version)
(error 'migrate-database!