#lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Ordered PostgreSQL schema migrations for existing wiki databases. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (require db racket/file racket/path racket/string "attachment-references.rkt" "concept-id.rkt" "config.rkt" "todo.rkt") (provide current-schema-version database-schema-version migrate-database!) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Supporting functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define current-schema-version 21) (define schema-1-statements (list #<2! db config) (query-exec db "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS mime_type TEXT") (query-exec db "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS content BYTEA") (let ((rows (query-rows db #< 2 cannot migrate attachment ~a: missing file ~a" stored-name (path->string path))) (let ((bytes (file->bytes path))) (query-exec db "UPDATE attachments SET content = $1, mime_type = $2, size = $3 WHERE id = $4" bytes (legacy-mime-type stored-name) (bytes-length bytes) attachment-id)))))) (query-exec db "UPDATE attachments SET mime_type = 'application/octet-stream' WHERE mime_type IS NULL") (query-exec db "ALTER TABLE attachments ALTER COLUMN mime_type SET NOT NULL") (query-exec db "ALTER TABLE attachments ALTER COLUMN content SET NOT NULL") (record-schema-version! db 2))) (define (replace-page-todos! db page-id markdown) (query-exec db "DELETE FROM todo_items WHERE page_id = $1" page-id) (for ((item (in-list (extract-todos markdown)))) (query-exec db #<3! db) (query-exec db #<4! db) (query-exec db #<5! db) (for ((row (in-list (query-rows db "SELECT id, markdown FROM pages WHERE archived = FALSE")))) (replace-page-todos! db (vector-ref row 0) (vector-ref row 1))) (record-schema-version! db 5)) (define (migrate-5->6! db) ;; Namespace is added here as well so a fresh migration chain can rebuild ;; attachment URLs before schema 7 creates the namespace indexes. (query-exec db "ALTER TABLE pages ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT ''") (query-exec db #<7! db) (query-exec db "ALTER TABLE pages ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT ''") (query-exec db "ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key") (query-exec db "CREATE UNIQUE INDEX IF NOT EXISTS pages_namespace_slug_idx ON pages(namespace, slug)") (query-exec db "CREATE INDEX IF NOT EXISTS pages_namespace_idx ON pages(lower(namespace), lower(title), title)") (record-schema-version! db 7)) (define (migrate-7->8! db) (query-exec db #<9! db) (query-exec db #<10! db) (query-exec db "ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT") (query-exec db "CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_idx ON users(lower(email)) WHERE email IS NOT NULL") (query-exec db #<11! db) (query-exec db #<12! db) ;; Older clients stored both automatic and explicit saves as `edit`. Preserve ;; the explicit saves, discard redundant autosave copies, and enforce the new ;; five-item manual history limit per CMap. (query-exec db #< 5 SQL ) (record-schema-version! db 12)) (define (migrate-12->13! db) (query-exec db #<> '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 #< '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 #<> '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 #<> '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 #< canonical_id) ) SQL ) (query-exec db "CREATE INDEX IF NOT EXISTS concept_aliases_canonical_idx ON concept_aliases(canonical_id)") (query-exec db #<> '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 #<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 #<> '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 #< '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 #<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 #<> '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 #< '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 #<> '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 #<> '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 #<> '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- 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 #<> '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. (used-ids (make-hash))) (for ([old-id (in-list old-ids)]) (let ((normalized (normalize-concept-id old-id))) (when normalized (hash-set! used-ids normalized #t)))) (letrec ((fresh-unused-id (λ () (let loop () (let ((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 #< '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 #<2! db config) (loop (database-schema-version db))) ((= version 2) (migrate-2->3! db) (loop (database-schema-version db))) ((= version 3) (migrate-3->4! db) (loop (database-schema-version db))) ((= version 4) (migrate-4->5! db) (loop (database-schema-version db))) ((= version 5) (migrate-5->6! db) (loop (database-schema-version db))) ((= version 6) (migrate-6->7! db) (loop (database-schema-version db))) ((= version 7) (migrate-7->8! db) (loop (database-schema-version db))) ((= version 8) (migrate-8->9! db) (loop (database-schema-version db))) ((= version 9) (migrate-9->10! db) (loop (database-schema-version db))) ((= version 10) (migrate-10->11! db) (loop (database-schema-version db))) ((= version 11) (migrate-11->12! db) (loop (database-schema-version db))) ((= version 12) (migrate-12->13! db) (loop (database-schema-version db))) ((= version 13) (migrate-13->14! db) (loop (database-schema-version db))) ((= version 14) (migrate-14->15! db) (loop (database-schema-version db))) ((= version 15) (migrate-15->16! db) (loop (database-schema-version db))) ((= version 16) (migrate-16->17! db) (loop (database-schema-version db))) ((= version 17) (migrate-17->18! db) (loop (database-schema-version db))) ((= version 18) (migrate-18->19! db) (loop (database-schema-version db))) ((= version 19) (migrate-19->20! db) (loop (database-schema-version db))) ((= version 20) (migrate-20->21! db) (loop (database-schema-version db))) ((> version current-schema-version) (error 'migrate-database! "database schema ~a is newer than this racket-wiki supports (~a)" version current-schema-version)) (else version))))))