diff --git a/.changeset/tidy-blocks-return.md b/.changeset/tidy-blocks-return.md new file mode 100644 index 0000000..fcdd582 --- /dev/null +++ b/.changeset/tidy-blocks-return.md @@ -0,0 +1,5 @@ +--- +"@geoprotocol/geo-sdk": minor +--- + +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 c6afe59..cf2749c 100644 --- a/src/core/blocks/data.test.ts +++ b/src/core/blocks/data.test.ts @@ -13,6 +13,15 @@ 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({ @@ -92,6 +101,26 @@ it('should generate ops for a data block entity with a name', () => { } }); +it('should use a provided block id for deterministic re-runs', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const blockId = Id('a1b2c3d4e5f64789a0b1c2d3e4f50617'); + const ops = make({ + fromId, + sourceType: 'QUERY', + id: 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({ diff --git a/src/core/blocks/data.ts b/src/core/blocks/data.ts index 37b4c58..05528be 100644 --- a/src/core/blocks/data.ts +++ b/src/core/blocks/data.ts @@ -1,94 +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 { generate } from '../../id-utils.js'; -import { SystemIds } from '../../system-ids.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; -}; +export type { DataBlockParams, DataBlockSourceType } from '../../ops/data-blocks.js'; /** - * Returns the ops to create an entity representing a Data Block. + * Returns the ops to create an entity representing a data block. + * + * @deprecated Use `Ops.dataBlocks.create(...)` to receive both the block ID and ops. * * @example * ```ts * const ops = DataBlock.make({ - * fromId: 'from-id', - * sourceType: 'COLLECTION', - * // optional - * position: 'position-string', - * name: 'name', + * fromId: pageId, + * sourceType: 'QUERY', + * id: blockId, * }); * ``` - * - * @param param args {@link TextBlockParams} - * @returns ops – The ops for the Data Block entity: {@link Op}[] */ -export function make({ fromId, sourceType, position, name }: DataBlockParams): Op[] { - const newBlockId = 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 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 da81fc9..361cfe5 100644 --- a/src/core/blocks/text.test.ts +++ b/src/core/blocks/text.test.ts @@ -5,6 +5,15 @@ 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({ @@ -61,6 +70,36 @@ it('should generate ops for a text block without position', () => { expect(blocksRelOp.position).toBeUndefined(); }); +it('should use a provided block id for deterministic re-runs', () => { + const fromId = Id('5871e8f7b71948979c4dcf7c518d32ef'); + const blockId = Id('a1b2c3d4e5f64789a0b1c2d3e4f50617'); + const ops = make({ + fromId, + text: 'test-text', + id: 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[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({ diff --git a/src/core/blocks/text.ts b/src/core/blocks/text.ts index 49df609..4b41fdc 100644 --- a/src/core/blocks/text.ts +++ b/src/core/blocks/text.ts @@ -1,66 +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 { generate } from '../../id-utils.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 }; +export type { TextBlockParams } from '../../ops/text-blocks.js'; /** - * Returns the ops to create an entity representing a Text Block. + * Returns the ops to create an entity representing a text block. + * + * @deprecated Use `Ops.textBlocks.create(...)` to receive both the block ID and ops. * * @example * ```ts * const ops = TextBlock.make({ - * fromId: 'from-id', - * text: 'text', - * // optional - * position: 'position-string', + * fromId: pageId, + * text: '# Heading', + * id: blockId, * }); * ``` - * - * @param param args {@link TextBlockParams} - * @returns ops – The ops for the Text Block entity: {@link Op}[] */ -export function make({ fromId, text, position }: TextBlockParams): Op[] { - const newBlockId = 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 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 }; +}