From 7a148000808c4862a5828a825b70d0471bdd41b3 Mon Sep 17 00:00:00 2001 From: 0xTaneja Date: Mon, 20 Jul 2026 15:17:21 +0000 Subject: [PATCH 1/2] feat!: TextBlock.make and DataBlock.make return { id, ops } and accept a stable id The block helpers minted a hidden fresh entity id on every call and returned only Op[], so callers could not reference the block they just created (views, columns) and every re-run of the same publish minted a duplicate block. Return a CreateResult ({ id, ops }) like createEntity and createRelation, and accept an optional validated id param: a script that derives the block id deterministically can check for the block's existence and skip re-publishing instead of duplicating content. BREAKING CHANGE: const ops = TextBlock.make(...) becomes const { ops } = TextBlock.make(...); same for DataBlock.make. --- .changeset/tidy-blocks-return.md | 7 +++++ src/core/blocks/data.test.ts | 43 +++++++++++++++++++++++--- src/core/blocks/data.ts | 25 ++++++++++----- src/core/blocks/text.test.ts | 53 +++++++++++++++++++++++++++++--- src/core/blocks/text.ts | 23 +++++++++----- 5 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 .changeset/tidy-blocks-return.md diff --git a/.changeset/tidy-blocks-return.md b/.changeset/tidy-blocks-return.md new file mode 100644 index 0000000..349edf1 --- /dev/null +++ b/.changeset/tidy-blocks-return.md @@ -0,0 +1,7 @@ +--- +"@geoprotocol/geo-sdk": minor +--- + +**Breaking:** `TextBlock.make` and `DataBlock.make` now return `{ id, ops }` (a `CreateResult`, matching `createEntity` / `createRelation`) instead of a bare `Op[]`, so callers can reference the block they just created (views, columns, idempotent re-runs). They also accept an optional `id` param: pass a stable, deterministically derived id to make re-publishes detectable — a script can check whether the block already exists and skip it instead of minting a duplicate on every run. + +Migration: `const ops = TextBlock.make(...)` becomes `const { ops } = TextBlock.make(...)`. diff --git a/src/core/blocks/data.test.ts b/src/core/blocks/data.test.ts index c6afe59..8a0ba32 100644 --- a/src/core/blocks/data.test.ts +++ b/src/core/blocks/data.test.ts @@ -15,7 +15,7 @@ import { make } from './data.js'; it('should generate ops for a data block entity', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, sourceType: 'QUERY', position: 'test-position', @@ -47,7 +47,7 @@ it('should generate ops for a data block entity', () => { it('should generate ops for a data block entity with a name', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, sourceType: 'QUERY', position: 'test-position', @@ -92,9 +92,44 @@ it('should generate ops for a data block entity with a name', () => { } }); +it('should return the created block id, matching the ops', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const { id, ops } = make({ + fromId, + sourceType: 'QUERY', + }); + + expect(id).toBeTruthy(); + const typeRelOp = ops[0] as CreateRelation; + expect(typeRelOp.from).toEqual(toGrcId(id)); + const blocksRelOp = ops[2] as CreateRelation; + expect(blocksRelOp.to).toEqual(toGrcId(id)); +}); + +it('should use a provided block id for deterministic re-runs', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const blockId = Id('a1b2c3d4e5f64789a0b1c2d3e4f50617'); + const { id, ops } = make({ + fromId, + sourceType: 'QUERY', + id: blockId, + }); + + expect(id).toBe(blockId); + const typeRelOp = ops[0] as CreateRelation; + expect(typeRelOp.from).toEqual(toGrcId(blockId)); + const blocksRelOp = ops[2] as CreateRelation; + expect(blocksRelOp.to).toEqual(toGrcId(blockId)); +}); + +it('should throw on an invalid provided id', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + expect(() => make({ fromId, sourceType: 'QUERY', id: 'not-a-valid-id' })).toThrow(); +}); + it('should generate ops for a COLLECTION data source type', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, sourceType: 'COLLECTION', position: 'a', @@ -108,7 +143,7 @@ it('should generate ops for a COLLECTION data source type', () => { it('should generate ops for a GEO data source type', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, sourceType: 'GEO', position: 'a', diff --git a/src/core/blocks/data.ts b/src/core/blocks/data.ts index 37b4c58..5a9f537 100644 --- a/src/core/blocks/data.ts +++ b/src/core/blocks/data.ts @@ -9,8 +9,9 @@ import type { Op } from '@geoprotocol/grc-20'; import { createRelation } from '../../graph/create-relation.js'; import { updateEntity } from '../../graph/update-entity.js'; import { Id } from '../../id.js'; -import { generate } from '../../id-utils.js'; +import { assertValid, generate } from '../../id-utils.js'; import { SystemIds } from '../../system-ids.js'; +import type { CreateResult } from '../../types.js'; import { BLOCKS, DATA_BLOCK, DATA_SOURCE_TYPE_RELATION_TYPE, NAME_PROPERTY, TYPES_PROPERTY } from '../ids/system.js'; type DataBlockSourceType = 'QUERY' | 'COLLECTION' | 'GEO'; @@ -31,27 +32,35 @@ type DataBlockParams = { sourceType: DataBlockSourceType; position?: string; name?: string; + id?: Id | string; }; /** - * Returns the ops to create an entity representing a Data Block. + * Returns the id and ops to create an entity representing a Data Block. + * + * Pass a stable `id` to make re-runs deterministic: a script that derives the + * block id can check for the block's existence and skip re-publishing instead + * of minting a duplicate. The returned id is also what a view or column + * configuration on the block must reference. * * @example * ```ts - * const ops = DataBlock.make({ + * const { id, ops } = DataBlock.make({ * fromId: 'from-id', * sourceType: 'COLLECTION', * // optional * position: 'position-string', * name: 'name', + * id: blockId, // optional and will be generated if not provided * }); * ``` * - * @param param args {@link TextBlockParams} - * @returns ops – The ops for the Data Block entity: {@link Op}[] + * @param param args {@link DataBlockParams} + * @returns – {@link CreateResult} */ -export function make({ fromId, sourceType, position, name }: DataBlockParams): Op[] { - const newBlockId = generate(); +export function make({ fromId, sourceType, position, name, id: providedId }: DataBlockParams): CreateResult { + if (providedId) assertValid(providedId, '`id` in `DataBlock.make`'); + const newBlockId = providedId ? Id(providedId) : generate(); const ops: Op[] = []; const { ops: dataBlockTypeOps } = createRelation({ @@ -90,5 +99,5 @@ export function make({ fromId, sourceType, position, name }: DataBlockParams): O ops.push(...nameOps); } - return ops; + return { id: newBlockId, ops }; } diff --git a/src/core/blocks/text.test.ts b/src/core/blocks/text.test.ts index da81fc9..9f8fae6 100644 --- a/src/core/blocks/text.test.ts +++ b/src/core/blocks/text.test.ts @@ -7,7 +7,7 @@ import { make } from './text.js'; it('should generate ops for a text block entity', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, text: 'test-text', position: 'test-position', @@ -48,7 +48,7 @@ it('should generate ops for a text block entity', () => { it('should generate ops for a text block without position', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, text: 'markdown content here', }); @@ -61,9 +61,54 @@ it('should generate ops for a text block without position', () => { expect(blocksRelOp.position).toBeUndefined(); }); +it('should return the created block id, matching the ops', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const { id, ops } = make({ + fromId, + text: 'test-text', + }); + + expect(id).toBeTruthy(); + const typeRelOp = ops[0] as CreateRelation; + expect(typeRelOp.from).toEqual(toGrcId(id)); + const blocksRelOp = ops[2] as CreateRelation; + expect(blocksRelOp.to).toEqual(toGrcId(id)); +}); + +it('should use a provided block id for deterministic re-runs', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const blockId = Id('a1b2c3d4e5f64789a0b1c2d3e4f50617'); + const { id, ops } = make({ + fromId, + text: 'test-text', + id: blockId, + }); + + expect(id).toBe(blockId); + const typeRelOp = ops[0] as CreateRelation; + expect(typeRelOp.from).toEqual(toGrcId(blockId)); + const blocksRelOp = ops[2] as CreateRelation; + expect(blocksRelOp.to).toEqual(toGrcId(blockId)); +}); + +it('should throw on an invalid provided id', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + expect(() => make({ fromId, text: 'test-text', id: 'not-a-valid-id' })).toThrow(); +}); + +it('should encode a dashed provided id to the same bytes as its dashless form', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const dashed = make({ fromId, text: 't', id: 'a1b2c3d4-e5f6-4789-a0b1-c2d3e4f50617' }); + const dashless = make({ fromId, text: 't', id: 'a1b2c3d4e5f64789a0b1c2d3e4f50617' }); + + const dashedTypeRel = dashed.ops[0] as CreateRelation; + const dashlessTypeRel = dashless.ops[0] as CreateRelation; + expect(dashedTypeRel.from).toEqual(dashlessTypeRel.from); +}); + it('should handle empty text', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const ops = make({ + const { ops } = make({ fromId, text: '', position: 'a', @@ -90,7 +135,7 @@ This is a paragraph. - Item 1 - Item 2`; - const ops = make({ + const { ops } = make({ fromId, text: multilineText, position: 'b', diff --git a/src/core/blocks/text.ts b/src/core/blocks/text.ts index 49df609..0d045e2 100644 --- a/src/core/blocks/text.ts +++ b/src/core/blocks/text.ts @@ -9,29 +9,36 @@ import type { Op } from '@geoprotocol/grc-20'; import { createRelation } from '../../graph/create-relation.js'; import { updateEntity } from '../../graph/update-entity.js'; import { Id } from '../../id.js'; -import { generate } from '../../id-utils.js'; +import { assertValid, generate } from '../../id-utils.js'; +import type { CreateResult } from '../../types.js'; import { BLOCKS, MARKDOWN_CONTENT, TEXT_BLOCK, TYPES_PROPERTY } from '../ids/system.js'; -type TextBlockParams = { fromId: string; text: string; position?: string }; +type TextBlockParams = { fromId: string; text: string; position?: string; id?: Id | string }; /** - * Returns the ops to create an entity representing a Text Block. + * Returns the id and ops to create an entity representing a Text Block. + * + * Pass a stable `id` to make re-runs deterministic: a script that derives the + * block id from its content can check for the block's existence and skip + * re-publishing instead of minting a duplicate. * * @example * ```ts - * const ops = TextBlock.make({ + * const { id, ops } = TextBlock.make({ * fromId: 'from-id', * text: 'text', * // optional * position: 'position-string', + * id: blockId, // optional and will be generated if not provided * }); * ``` * * @param param args {@link TextBlockParams} - * @returns ops – The ops for the Text Block entity: {@link Op}[] + * @returns – {@link CreateResult} */ -export function make({ fromId, text, position }: TextBlockParams): Op[] { - const newBlockId = generate(); +export function make({ fromId, text, position, id: providedId }: TextBlockParams): CreateResult { + if (providedId) assertValid(providedId, '`id` in `TextBlock.make`'); + const newBlockId = providedId ? Id(providedId) : generate(); const ops: Op[] = []; @@ -62,5 +69,5 @@ export function make({ fromId, text, position }: TextBlockParams): Op[] { }); ops.push(...textBlockRelationOps); - return ops; + return { id: newBlockId, ops }; } From 5dff56f053e60e17d2b5ff431c505d27822dafa3 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 30 Jul 2026 17:28:05 +0200 Subject: [PATCH 2/2] fix(api): preserve block builder compatibility --- .changeset/tidy-blocks-return.md | 4 +- README.md | 21 +++++++ src/blocks.ts | 2 + src/core/blocks/data.test.ts | 34 +++++------ src/core/blocks/data.ts | 97 ++++---------------------------- src/core/blocks/text.test.ts | 38 ++++++------- src/core/blocks/text.ts | 67 ++++------------------ src/ops/data-blocks.ts | 95 +++++++++++++++++++++++++++++++ src/ops/index.test.ts | 42 ++++++++++++++ src/ops/index.ts | 2 + src/ops/text-blocks.ts | 65 +++++++++++++++++++++ 11 files changed, 280 insertions(+), 187 deletions(-) create mode 100644 src/ops/data-blocks.ts create mode 100644 src/ops/text-blocks.ts diff --git a/.changeset/tidy-blocks-return.md b/.changeset/tidy-blocks-return.md index 349edf1..fcdd582 100644 --- a/.changeset/tidy-blocks-return.md +++ b/.changeset/tidy-blocks-return.md @@ -2,6 +2,4 @@ "@geoprotocol/geo-sdk": minor --- -**Breaking:** `TextBlock.make` and `DataBlock.make` now return `{ id, ops }` (a `CreateResult`, matching `createEntity` / `createRelation`) instead of a bare `Op[]`, so callers can reference the block they just created (views, columns, idempotent re-runs). They also accept an optional `id` param: pass a stable, deterministically derived id to make re-publishes detectable — a script can check whether the block already exists and skip it instead of minting a duplicate on every run. - -Migration: `const ops = TextBlock.make(...)` becomes `const { ops } = TextBlock.make(...)`. +Add `Ops.textBlocks.create` and `Ops.dataBlocks.create`, returning `{ id, ops }` and accepting an optional stable block ID. The existing `TextBlock.make` and `DataBlock.make` helpers remain compatible with their `Op[]` return type and are now deprecated in favor of the new builders. diff --git a/README.md b/README.md index e48a02d..ce6dadb 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,25 @@ const { ops } = Ops.relations.create({ }); ``` +### Blocks + +Create text and data blocks with generated or stable IDs: + +```ts +import { Ops } from "@geoprotocol/geo-sdk"; + +const { id: textBlockId, ops: textBlockOps } = Ops.textBlocks.create({ + fromId: pageId, + text: "# Heading", +}); + +const { id: dataBlockId, ops: dataBlockOps } = Ops.dataBlocks.create({ + fromId: pageId, + sourceType: "QUERY", + id: stableDataBlockId, +}); +``` + ### Images Image creation goes through `geo.images.create(...)`. The configured client uploads the image, detects dimensions when possible, and returns the image entity ops. @@ -1110,6 +1129,8 @@ The legacy namespaces remain exported for compatibility, but new code should pre | `Graph.deleteEntity(...)` | `geo.entities.delete(...)` | | `Graph.createImage(...)` | `geo.images.create(...)` | | `Graph.createComment(...)` | `geo.comments.create(...)` or `Ops.comments.create(...)` with supplied reply context | +| `TextBlock.make(...)` | `Ops.textBlocks.create(...)` | +| `DataBlock.make(...)` | `Ops.dataBlocks.create(...)` | | `Ipfs.publishEdit(...)` | `geo.personalSpaces.publishEdit(...)` or `geo.daoSpaces.proposeEdit(...)` | | `Ipfs.uploadImage(...)` | `geo.storage.uploadImage(...)` | | `Ipfs.uploadCSV(...)` | `geo.storage.uploadCSV(...)` | diff --git a/src/blocks.ts b/src/blocks.ts index fc71f81..6207220 100644 --- a/src/blocks.ts +++ b/src/blocks.ts @@ -3,6 +3,7 @@ * in TypeScript. * * @since 0.0.6 + * @deprecated Use `Ops.dataBlocks.create(...)`. */ export * as DataBlock from './core/blocks/data.js'; @@ -11,5 +12,6 @@ export * as DataBlock from './core/blocks/data.js'; * in TypeScript. * * @since 0.0.6 + * @deprecated Use `Ops.textBlocks.create(...)`. */ export * as TextBlock from './core/blocks/text.js'; diff --git a/src/core/blocks/data.test.ts b/src/core/blocks/data.test.ts index 8a0ba32..cf2749c 100644 --- a/src/core/blocks/data.test.ts +++ b/src/core/blocks/data.test.ts @@ -13,9 +13,18 @@ import { } from '../ids/system.js'; import { make } from './data.js'; +it('preserves the legacy array return contract', () => { + const ops = make({ + fromId: Id('5871e8f7b71948979c4dcf7c518d32ef'), + sourceType: 'QUERY', + }); + + expect(Array.isArray(ops)).toBe(true); +}); + it('should generate ops for a data block entity', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, sourceType: 'QUERY', position: 'test-position', @@ -47,7 +56,7 @@ it('should generate ops for a data block entity', () => { it('should generate ops for a data block entity with a name', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, sourceType: 'QUERY', position: 'test-position', @@ -92,30 +101,15 @@ it('should generate ops for a data block entity with a name', () => { } }); -it('should return the created block id, matching the ops', () => { - const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { id, ops } = make({ - fromId, - sourceType: 'QUERY', - }); - - expect(id).toBeTruthy(); - const typeRelOp = ops[0] as CreateRelation; - expect(typeRelOp.from).toEqual(toGrcId(id)); - const blocksRelOp = ops[2] as CreateRelation; - expect(blocksRelOp.to).toEqual(toGrcId(id)); -}); - it('should use a provided block id for deterministic re-runs', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); const blockId = Id('a1b2c3d4e5f64789a0b1c2d3e4f50617'); - const { id, ops } = make({ + const ops = make({ fromId, sourceType: 'QUERY', id: blockId, }); - expect(id).toBe(blockId); const typeRelOp = ops[0] as CreateRelation; expect(typeRelOp.from).toEqual(toGrcId(blockId)); const blocksRelOp = ops[2] as CreateRelation; @@ -129,7 +123,7 @@ it('should throw on an invalid provided id', () => { it('should generate ops for a COLLECTION data source type', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, sourceType: 'COLLECTION', position: 'a', @@ -143,7 +137,7 @@ it('should generate ops for a COLLECTION data source type', () => { it('should generate ops for a GEO data source type', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, sourceType: 'GEO', position: 'a', diff --git a/src/core/blocks/data.ts b/src/core/blocks/data.ts index 5a9f537..05528be 100644 --- a/src/core/blocks/data.ts +++ b/src/core/blocks/data.ts @@ -1,103 +1,28 @@ /** - * This module provides utility functions for working with data blocks - * in TypeScript. + * This module provides legacy utility functions for working with data blocks. * * @since 0.0.6 */ import type { Op } from '@geoprotocol/grc-20'; -import { createRelation } from '../../graph/create-relation.js'; -import { updateEntity } from '../../graph/update-entity.js'; -import { Id } from '../../id.js'; -import { assertValid, generate } from '../../id-utils.js'; -import { SystemIds } from '../../system-ids.js'; -import type { CreateResult } from '../../types.js'; -import { BLOCKS, DATA_BLOCK, DATA_SOURCE_TYPE_RELATION_TYPE, NAME_PROPERTY, TYPES_PROPERTY } from '../ids/system.js'; +import { create, type DataBlockParams } from '../../ops/data-blocks.js'; -type DataBlockSourceType = 'QUERY' | 'COLLECTION' | 'GEO'; - -function getSourceTypeId(sourceType: DataBlockSourceType) { - switch (sourceType) { - case 'COLLECTION': - return SystemIds.COLLECTION_DATA_SOURCE; - case 'GEO': - return SystemIds.ALL_OF_GEO_DATA_SOURCE; - case 'QUERY': - return SystemIds.QUERY_DATA_SOURCE; - } -} - -type DataBlockParams = { - fromId: string; - sourceType: DataBlockSourceType; - position?: string; - name?: string; - id?: Id | string; -}; +export type { DataBlockParams, DataBlockSourceType } from '../../ops/data-blocks.js'; /** - * Returns the id and ops to create an entity representing a Data Block. + * Returns the ops to create an entity representing a data block. * - * Pass a stable `id` to make re-runs deterministic: a script that derives the - * block id can check for the block's existence and skip re-publishing instead - * of minting a duplicate. The returned id is also what a view or column - * configuration on the block must reference. + * @deprecated Use `Ops.dataBlocks.create(...)` to receive both the block ID and ops. * * @example * ```ts - * const { id, ops } = DataBlock.make({ - * fromId: 'from-id', - * sourceType: 'COLLECTION', - * // optional - * position: 'position-string', - * name: 'name', - * id: blockId, // optional and will be generated if not provided + * const ops = DataBlock.make({ + * fromId: pageId, + * sourceType: 'QUERY', + * id: blockId, * }); * ``` - * - * @param param args {@link DataBlockParams} - * @returns – {@link CreateResult} */ -export function make({ fromId, sourceType, position, name, id: providedId }: DataBlockParams): CreateResult { - if (providedId) assertValid(providedId, '`id` in `DataBlock.make`'); - const newBlockId = providedId ? Id(providedId) : generate(); - - const ops: Op[] = []; - const { ops: dataBlockTypeOps } = createRelation({ - fromEntity: newBlockId, - type: TYPES_PROPERTY, - toEntity: DATA_BLOCK, - }); - ops.push(...dataBlockTypeOps); - - const { ops: dataBlockSourceTypeOps } = createRelation({ - fromEntity: newBlockId, - type: DATA_SOURCE_TYPE_RELATION_TYPE, - toEntity: getSourceTypeId(sourceType), - }); - ops.push(...dataBlockSourceTypeOps); - - const { ops: dataBlockRelationOps } = createRelation({ - fromEntity: Id(fromId), - type: BLOCKS, - toEntity: Id(newBlockId), - position, - }); - ops.push(...dataBlockRelationOps); - - if (name) { - const { ops: nameOps } = updateEntity({ - id: newBlockId, - values: [ - { - property: NAME_PROPERTY, - type: 'text', - value: name, - }, - ], - }); - ops.push(...nameOps); - } - - return { id: newBlockId, ops }; +export function make(params: DataBlockParams): Op[] { + return create(params).ops; } diff --git a/src/core/blocks/text.test.ts b/src/core/blocks/text.test.ts index 9f8fae6..361cfe5 100644 --- a/src/core/blocks/text.test.ts +++ b/src/core/blocks/text.test.ts @@ -5,9 +5,18 @@ import { toGrcId } from '../../id-utils.js'; import { BLOCKS, MARKDOWN_CONTENT, TEXT_BLOCK, TYPES_PROPERTY } from '../ids/system.js'; import { make } from './text.js'; +it('preserves the legacy array return contract', () => { + const ops = make({ + fromId: Id('5871e8f7b71948979c4dcf7c518d32ef'), + text: 'test-text', + }); + + expect(Array.isArray(ops)).toBe(true); +}); + it('should generate ops for a text block entity', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, text: 'test-text', position: 'test-position', @@ -48,7 +57,7 @@ it('should generate ops for a text block entity', () => { it('should generate ops for a text block without position', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, text: 'markdown content here', }); @@ -61,30 +70,15 @@ it('should generate ops for a text block without position', () => { expect(blocksRelOp.position).toBeUndefined(); }); -it('should return the created block id, matching the ops', () => { - const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { id, ops } = make({ - fromId, - text: 'test-text', - }); - - expect(id).toBeTruthy(); - const typeRelOp = ops[0] as CreateRelation; - expect(typeRelOp.from).toEqual(toGrcId(id)); - const blocksRelOp = ops[2] as CreateRelation; - expect(blocksRelOp.to).toEqual(toGrcId(id)); -}); - it('should use a provided block id for deterministic re-runs', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); const blockId = Id('a1b2c3d4e5f64789a0b1c2d3e4f50617'); - const { id, ops } = make({ + const ops = make({ fromId, text: 'test-text', id: blockId, }); - expect(id).toBe(blockId); const typeRelOp = ops[0] as CreateRelation; expect(typeRelOp.from).toEqual(toGrcId(blockId)); const blocksRelOp = ops[2] as CreateRelation; @@ -101,14 +95,14 @@ it('should encode a dashed provided id to the same bytes as its dashless form', const dashed = make({ fromId, text: 't', id: 'a1b2c3d4-e5f6-4789-a0b1-c2d3e4f50617' }); const dashless = make({ fromId, text: 't', id: 'a1b2c3d4e5f64789a0b1c2d3e4f50617' }); - const dashedTypeRel = dashed.ops[0] as CreateRelation; - const dashlessTypeRel = dashless.ops[0] as CreateRelation; + const dashedTypeRel = dashed[0] as CreateRelation; + const dashlessTypeRel = dashless[0] as CreateRelation; expect(dashedTypeRel.from).toEqual(dashlessTypeRel.from); }); it('should handle empty text', () => { const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); - const { ops } = make({ + const ops = make({ fromId, text: '', position: 'a', @@ -135,7 +129,7 @@ This is a paragraph. - Item 1 - Item 2`; - const { ops } = make({ + const ops = make({ fromId, text: multilineText, position: 'b', diff --git a/src/core/blocks/text.ts b/src/core/blocks/text.ts index 0d045e2..4b41fdc 100644 --- a/src/core/blocks/text.ts +++ b/src/core/blocks/text.ts @@ -1,73 +1,28 @@ /** - * This module provides utility functions for working with text blocks - * in TypeScript. + * This module provides legacy utility functions for working with text blocks. * * @since 0.0.6 */ import type { Op } from '@geoprotocol/grc-20'; -import { createRelation } from '../../graph/create-relation.js'; -import { updateEntity } from '../../graph/update-entity.js'; -import { Id } from '../../id.js'; -import { assertValid, generate } from '../../id-utils.js'; -import type { CreateResult } from '../../types.js'; -import { BLOCKS, MARKDOWN_CONTENT, TEXT_BLOCK, TYPES_PROPERTY } from '../ids/system.js'; +import { create, type TextBlockParams } from '../../ops/text-blocks.js'; -type TextBlockParams = { fromId: string; text: string; position?: string; id?: Id | string }; +export type { TextBlockParams } from '../../ops/text-blocks.js'; /** - * Returns the id and ops to create an entity representing a Text Block. + * Returns the ops to create an entity representing a text block. * - * Pass a stable `id` to make re-runs deterministic: a script that derives the - * block id from its content can check for the block's existence and skip - * re-publishing instead of minting a duplicate. + * @deprecated Use `Ops.textBlocks.create(...)` to receive both the block ID and ops. * * @example * ```ts - * const { id, ops } = TextBlock.make({ - * fromId: 'from-id', - * text: 'text', - * // optional - * position: 'position-string', - * id: blockId, // optional and will be generated if not provided + * const ops = TextBlock.make({ + * fromId: pageId, + * text: '# Heading', + * id: blockId, * }); * ``` - * - * @param param args {@link TextBlockParams} - * @returns – {@link CreateResult} */ -export function make({ fromId, text, position, id: providedId }: TextBlockParams): CreateResult { - if (providedId) assertValid(providedId, '`id` in `TextBlock.make`'); - const newBlockId = providedId ? Id(providedId) : generate(); - - const ops: Op[] = []; - - const { ops: textBlockTypeOps } = createRelation({ - fromEntity: newBlockId, - type: TYPES_PROPERTY, - toEntity: TEXT_BLOCK, - }); - ops.push(...textBlockTypeOps); - - const { ops: textBlockMarkdownTextOps } = updateEntity({ - id: newBlockId, - values: [ - { - property: MARKDOWN_CONTENT, - type: 'text', - value: text, - }, - ], - }); - ops.push(...textBlockMarkdownTextOps); - - const { ops: textBlockRelationOps } = createRelation({ - fromEntity: Id(fromId), - type: BLOCKS, - toEntity: newBlockId, - position, - }); - ops.push(...textBlockRelationOps); - - return { id: newBlockId, ops }; +export function make(params: TextBlockParams): Op[] { + return create(params).ops; } diff --git a/src/ops/data-blocks.ts b/src/ops/data-blocks.ts new file mode 100644 index 0000000..06822dc --- /dev/null +++ b/src/ops/data-blocks.ts @@ -0,0 +1,95 @@ +import type { Op } from '@geoprotocol/grc-20'; +import { + BLOCKS, + DATA_BLOCK, + DATA_SOURCE_TYPE_RELATION_TYPE, + NAME_PROPERTY, + TYPES_PROPERTY, +} from '../core/ids/system.js'; +import { createRelation } from '../graph/create-relation.js'; +import { updateEntity } from '../graph/update-entity.js'; +import { Id, type Id as IdType } from '../id.js'; +import { assertValid, generate } from '../id-utils.js'; +import { SystemIds } from '../system-ids.js'; +import type { CreateResult } from '../types.js'; + +export type DataBlockSourceType = 'QUERY' | 'COLLECTION' | 'GEO'; + +export type DataBlockParams = { + fromId: IdType | string; + sourceType: DataBlockSourceType; + position?: string; + name?: string; + id?: IdType | string; +}; + +function getSourceTypeId(sourceType: DataBlockSourceType) { + switch (sourceType) { + case 'COLLECTION': + return SystemIds.COLLECTION_DATA_SOURCE; + case 'GEO': + return SystemIds.ALL_OF_GEO_DATA_SOURCE; + case 'QUERY': + return SystemIds.QUERY_DATA_SOURCE; + } +} + +/** + * Builds ops to create a data block. + * + * @example + * ```ts + * const { id, ops } = Ops.dataBlocks.create({ + * fromId: pageId, + * sourceType: 'QUERY', + * id: blockId, + * }); + * ``` + * + * @param params Parent entity, source type, display fields, and optional stable block ID. + * @returns Generated or supplied block ID and create ops. + * @throws When a supplied ID is invalid. + */ +export function create({ fromId, sourceType, position, name, id: providedId }: DataBlockParams): CreateResult { + if (providedId) assertValid(providedId, '`id` in `Ops.dataBlocks.create`'); + const id = providedId ? Id(providedId) : generate(); + const ops: Op[] = []; + + const { ops: dataBlockTypeOps } = createRelation({ + fromEntity: id, + type: TYPES_PROPERTY, + toEntity: DATA_BLOCK, + }); + ops.push(...dataBlockTypeOps); + + const { ops: dataBlockSourceTypeOps } = createRelation({ + fromEntity: id, + type: DATA_SOURCE_TYPE_RELATION_TYPE, + toEntity: getSourceTypeId(sourceType), + }); + ops.push(...dataBlockSourceTypeOps); + + const { ops: dataBlockRelationOps } = createRelation({ + fromEntity: Id(fromId), + type: BLOCKS, + toEntity: id, + position, + }); + ops.push(...dataBlockRelationOps); + + if (name) { + const { ops: nameOps } = updateEntity({ + id, + values: [ + { + property: NAME_PROPERTY, + type: 'text', + value: name, + }, + ], + }); + ops.push(...nameOps); + } + + return { id, ops }; +} diff --git a/src/ops/index.test.ts b/src/ops/index.test.ts index 1347a01..e6a08e0 100644 --- a/src/ops/index.test.ts +++ b/src/ops/index.test.ts @@ -138,4 +138,46 @@ describe('Ops', () => { ); expect(Ops.proposalReviews.update(updateParams)).toEqual(updateProposalReview(updateParams)); }); + + it('creates text and data blocks with referenceable ids', () => { + const fromId = generate(); + const textBlockId = generate(); + const dataBlockId = generate(); + + const textBlock = Ops.textBlocks.create({ + id: textBlockId, + fromId, + text: '# Heading', + }); + const dataBlock = Ops.dataBlocks.create({ + id: dataBlockId, + fromId, + sourceType: 'QUERY', + }); + + expect(textBlock.id).toBe(textBlockId); + expect((textBlock.ops[0] as CreateRelation).from).toEqual(toGrcId(textBlockId)); + expect((textBlock.ops[2] as CreateRelation).to).toEqual(toGrcId(textBlockId)); + expect(dataBlock.id).toBe(dataBlockId); + expect((dataBlock.ops[0] as CreateRelation).from).toEqual(toGrcId(dataBlockId)); + expect((dataBlock.ops[2] as CreateRelation).to).toEqual(toGrcId(dataBlockId)); + }); + + it('returns generated block ids used by the ops', () => { + const fromId = generate(); + const textBlock = Ops.textBlocks.create({ fromId, text: '# Heading' }); + const dataBlock = Ops.dataBlocks.create({ fromId, sourceType: 'QUERY' }); + + expect((textBlock.ops[0] as CreateRelation).from).toEqual(toGrcId(textBlock.id)); + expect((textBlock.ops[2] as CreateRelation).to).toEqual(toGrcId(textBlock.id)); + expect((dataBlock.ops[0] as CreateRelation).from).toEqual(toGrcId(dataBlock.id)); + expect((dataBlock.ops[2] as CreateRelation).to).toEqual(toGrcId(dataBlock.id)); + }); + + it('rejects invalid block ids', () => { + const fromId = generate(); + + expect(() => Ops.textBlocks.create({ id: 'invalid', fromId, text: 'Text' })).toThrow('Invalid id'); + expect(() => Ops.dataBlocks.create({ id: 'invalid', fromId, sourceType: 'QUERY' })).toThrow('Invalid id'); + }); }); diff --git a/src/ops/index.ts b/src/ops/index.ts index 6abab97..2d7d318 100644 --- a/src/ops/index.ts +++ b/src/ops/index.ts @@ -1,7 +1,9 @@ export * as comments from './comments.js'; +export * as dataBlocks from './data-blocks.js'; export * as entities from './entities.js'; export * as properties from './properties.js'; export * as proposalReviews from './proposal-reviews.js'; export * as ranks from './ranks.js'; export * as relations from './relations.js'; +export * as textBlocks from './text-blocks.js'; export * as types from './types.js'; diff --git a/src/ops/text-blocks.ts b/src/ops/text-blocks.ts new file mode 100644 index 0000000..f948149 --- /dev/null +++ b/src/ops/text-blocks.ts @@ -0,0 +1,65 @@ +import type { Op } from '@geoprotocol/grc-20'; +import { BLOCKS, MARKDOWN_CONTENT, TEXT_BLOCK, TYPES_PROPERTY } from '../core/ids/system.js'; +import { createRelation } from '../graph/create-relation.js'; +import { updateEntity } from '../graph/update-entity.js'; +import { Id, type Id as IdType } from '../id.js'; +import { assertValid, generate } from '../id-utils.js'; +import type { CreateResult } from '../types.js'; + +export type TextBlockParams = { + fromId: IdType | string; + text: string; + position?: string; + id?: IdType | string; +}; + +/** + * Builds ops to create a text block. + * + * @example + * ```ts + * const { id, ops } = Ops.textBlocks.create({ + * fromId: pageId, + * text: '# Heading', + * id: blockId, + * }); + * ``` + * + * @param params Parent entity, markdown content, position, and optional stable block ID. + * @returns Generated or supplied block ID and create ops. + * @throws When a supplied ID is invalid. + */ +export function create({ fromId, text, position, id: providedId }: TextBlockParams): CreateResult { + if (providedId) assertValid(providedId, '`id` in `Ops.textBlocks.create`'); + const id = providedId ? Id(providedId) : generate(); + const ops: Op[] = []; + + const { ops: textBlockTypeOps } = createRelation({ + fromEntity: id, + type: TYPES_PROPERTY, + toEntity: TEXT_BLOCK, + }); + ops.push(...textBlockTypeOps); + + const { ops: textBlockMarkdownTextOps } = updateEntity({ + id, + values: [ + { + property: MARKDOWN_CONTENT, + type: 'text', + value: text, + }, + ], + }); + ops.push(...textBlockMarkdownTextOps); + + const { ops: textBlockRelationOps } = createRelation({ + fromEntity: Id(fromId), + type: BLOCKS, + toEntity: id, + position, + }); + ops.push(...textBlockRelationOps); + + return { id, ops }; +}