Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/dual-source-cross-form-convergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": major
"@objectstack/plugin-sharing": major
---

feat(spec)!: resolve the three cross-form dual-source names — ShareRecipientType, TransformType, suggestFieldType (#4539)

Three `dual-source-exports.baseline.json` rows where the two declarations
sharing a name did not even share a FORM (type vs const, or two unrelated
functions), so a wrong import-path pick had no shape overlap to hide behind
and failed far from the cause. Each judged against a three-repo import-level
scan (framework, cloud, objectui — the latter two contained zero references
to all three names). All three rows are deleted from the baseline.

**Renamed — `./contracts` `ShareRecipientType` → `RecordShareRecipientType`:**

Two live concepts shared the name. The security zod enum
(`user | team | position | unit_and_subordinates | business_unit`) is the
authorable sharing-RULE recipient vocabulary and keeps the name. The contracts
type describes a different thing — the `recipient_type` a `sys_record_share`
ROW may carry — and its claim to "mirror spec/security" had been false since
`group`→`team`/`guest` were retired there. Its member set is now aligned to
the storage-side gate it actually mirrors, the `SysRecordShare`
`recipient_type` select: `role` (never persistable, zero producers) is
replaced by `position`. Only `user` is enforced (and written) today;
`ISharingService.grant` keeps refusing every other value (ADR-0078).
Fix: `import type { ShareRecipientType } from '@objectstack/spec/contracts'`
(or from `@objectstack/plugin-sharing`, whose re-export is renamed in
lockstep) → `RecordShareRecipientType`; code that named the `'role'` member
was describing a value no row could ever hold — use the rule vocabulary
(`SharingRuleRecipientType`) if a role recipient was meant.

**Renamed — `./shared` `TransformTypeSchema` / `TransformType` →
`FieldMappingTransformSchema` / `FieldMappingTransform`:**

`./data`'s `TransformType` (the authorable import-mapping enum
`none | constant | lookup | split | join | javascript | map`) is the live
declaration and keeps the name. `./shared` exported `TransformType` as the
inferred type of `TransformTypeSchema` — a differently-shaped discriminated
union of transform CONFIG objects — with zero importers for either name in
all three repos. The shared pair is renamed (not just the alias deleted):
the docs generator derives `import type { X }` examples by stripping
`Schema` from each schema const, so an alias-less `TransformTypeSchema`
would have kept generating a reference to an export that no longer exists.
Fix: `TransformTypeSchema` → `FieldMappingTransformSchema`,
`import type { TransformType } from '@objectstack/spec/shared'` →
`FieldMappingTransform` (same shape); importers who meant the import-mapping
enum import `TransformType` from `@objectstack/spec/data`.

**Renamed — `./data` `suggestFieldType` → `suggestFieldTypeForSqlType`:**

The only function-kind dual-source. The two implementations were never forks
of one function — different signatures, semantics and return types:
`shared/suggestions.zod.ts` (kept on `.` / `./shared` under the original
name) is the typo-suggester for an invalid authored FieldType
(`(input: string) => string[]`, alias table + Levenshtein, feeds the zod
error map), while `data/type-compat.ts` is the deterministic SQL-column →
FieldType mapper for external-datasource drafts
(`(rawType, dialect?) => FieldType | undefined`, ADR-0015 §4.6). Same input,
divergent outputs — `('varchar(255)')` → `[]` vs `'text'`; `('text_area')` →
`['textarea']` vs `undefined`; `('int')` → `['number']` vs `'number'` — and
the wrong pick compiled wherever the result was only truthiness-checked
(`[]` is truthy). Behavioral divergence is now pinned in
`data/type-compat.test.ts`.
Fix: `import { suggestFieldType } from '@objectstack/spec/data'` →
`suggestFieldTypeForSqlType` (same signature); imports from the root entry
or `./shared` are unaffected.
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Common utilities used across all protocols.
| **[Expression](/docs/references/shared/expression)** | `expression.zod.ts` | Expression, ExpressionInput | CEL expression values and inputs |
| **[HTTP](/docs/references/shared/http)** | `http.zod.ts` | HttpRequest, HttpMethod, CorsConfig | HTTP utilities |
| **[Identifiers](/docs/references/shared/identifiers)** | `identifiers.zod.ts` | SystemIdentifier, SnakeCaseIdentifier | Standard ID patterns |
| **[Mapping](/docs/references/shared/mapping)** | `mapping.zod.ts` | FieldMapping, TransformType | Field mapping utilities |
| **[Mapping](/docs/references/shared/mapping)** | `mapping.zod.ts` | FieldMapping, FieldMappingTransform | Field mapping utilities |
| **[Connector Auth](/docs/references/shared/connector-auth)** | `connector-auth.zod.ts` | ConnectorAuthConfig | Connector auth patterns |

## QA Protocol (1 schema)
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/shared/mapping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ defaultValue: 'Unknown'
## TypeScript Usage

```typescript
import { FieldMapping, TransformType } from '@objectstack/spec/shared';
import type { FieldMapping, TransformType } from '@objectstack/spec/shared';
import { FieldMapping, FieldMappingTransform } from '@objectstack/spec/shared';
import type { FieldMapping, FieldMappingTransform } from '@objectstack/spec/shared';

// Validate data
const result = FieldMapping.parse(data);
Expand All @@ -85,7 +85,7 @@ const result = FieldMapping.parse(data);

---

## TransformType
## FieldMappingTransform

### Union Options

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-sharing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export type {
GrantShareInput,
SharingExecutionContext,
ShareAccessLevel,
ShareRecipientType,
RecordShareRecipientType,
ShareSource,
SharingRuleRow,
DefineSharingRuleInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
*
* Conventions:
* - `object_name` is the short object name (e.g. `account`, `lead`).
* - `recipient_type` mirrors `ShareRecipientType` from the spec
* (`user` is enforced today; `group`/`position` are persisted for
* forward-compatibility).
* - `recipient_type` mirrors `RecordShareRecipientType` from
* `@objectstack/spec/contracts` (`user` is enforced today;
* `group`/`position` are persisted for forward-compatibility).
* - `source = 'manual'` rows are created by a user via the REST
* `POST /data/:object/:id/shares` endpoint. `source = 'rule'` rows
* are materialised by the sharing-rule evaluator (future); the
Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/plugin-sharing/src/sharing-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,10 +939,12 @@ describe('[ADR-0111 D7] no inert grants', () => {
});

it('refuses non-user recipient types instead of persisting rows no gate reads', async () => {
// Every non-`user` member of RecordShareRecipientType — no `as any`
// needed since #4539 aligned the contract type to the storage select.
for (const recipientType of ['group', 'position', 'unit_and_subordinates', 'guest'] as const) {
await expect(
svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'g1', recipientType: recipientType as any },
{ object: 'account', recordId: 'a1', recipientId: 'g1', recipientType },
{ userId: 'alice' },
),
).rejects.toThrow(/VALIDATION_FAILED/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type {
} from '@objectstack/spec/contracts';
import type { SchemaDiffEntry } from '@objectstack/spec/shared';
import {
suggestFieldType,
suggestFieldTypeForSqlType,
isCompatible,
ExternalCatalogSchema,
type ExternalCatalog,
Expand Down Expand Up @@ -211,7 +211,7 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
if (exclude.has(col.name)) continue;

const fieldName = opts.rename?.[col.name] ?? col.name;
const suggested = suggestFieldType(col.type, dialect);
const suggested = suggestFieldTypeForSqlType(col.type, dialect);
const fieldType: FieldType = suggested ?? 'text';
if (!suggested) {
review.push({
Expand Down Expand Up @@ -310,7 +310,7 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
sqlType: c.type,
nullable: c.nullable,
primaryKey: c.primaryKey,
suggestedFieldType: suggestFieldType(c.type, schema.dialect as SqlDialect),
suggestedFieldType: suggestFieldTypeForSqlType(c.type, schema.dialect as SqlDialect),
})),
};
}),
Expand Down
8 changes: 4 additions & 4 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@
"resolveSearchFields (function)",
"sequenceWidth (function)",
"stripLegacyApiMethods (function)",
"suggestFieldType (function)",
"suggestFieldTypeForSqlType (function)",
"utcInstantMs (function)",
"validateDriverConfig (function)",
"valueSchemaFor (function)"
Expand Down Expand Up @@ -3840,6 +3840,7 @@
"RealtimeSubscriptionFilter (interface)",
"RealtimeSubscriptionOptions (interface)",
"RecordShare (interface)",
"RecordShareRecipientType (type)",
"RemoteTable (interface)",
"ReportFormat (type)",
"ReportQuery (interface)",
Expand Down Expand Up @@ -3877,7 +3878,6 @@
"ShareLinkAudience (type)",
"ShareLinkExecutionContext (interface)",
"ShareLinkPermission (type)",
"ShareRecipientType (type)",
"ShareSource (type)",
"SharingExecutionContext (interface)",
"SharingRuleEvaluationResult (interface)",
Expand Down Expand Up @@ -4455,6 +4455,8 @@
"F (const)",
"FieldMapping (type)",
"FieldMappingSchema (const)",
"FieldMappingTransform (type)",
"FieldMappingTransformSchema (const)",
"FieldName (type)",
"FieldNameSchema (const)",
"FlowName (type)",
Expand Down Expand Up @@ -4507,8 +4509,6 @@
"SystemIdentifierSchema (const)",
"TemplateExpressionInput (type)",
"TemplateExpressionInputSchema (const)",
"TransformType (type)",
"TransformTypeSchema (const)",
"VISIBILITY_ALIAS_KEYS (const)",
"ViewName (type)",
"ViewNameSchema (const)",
Expand Down
5 changes: 1 addition & 4 deletions packages/spec/dual-source-exports.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,11 @@
"RetryPolicySchema — [./automation (const)] ≠ [./system (const)]",
"Session — [./api (type)] ≠ [./identity (type)]",
"SessionSchema — [./api (const)] ≠ [./identity (const)]",
"ShareRecipientType — [./contracts (type)] ≠ [./security (const)]",
"TenantPlan — [./cloud (type)] ≠ [./system (type)]",
"TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]",
"TransformType — [./data (const)] ≠ [./shared (type)]",
"WebhookConfig — [./api (type)] ≠ [./integration (type)]",
"WebhookConfigSchema — [./api (const)] ≠ [./integration (const)]",
"WebhookEvent — [./api (type)] ≠ [./integration (type)]",
"WebhookEventSchema — [./api (const)] ≠ [./integration (const)]",
"suggestFieldType — [., ./shared (function)] ≠ [./data (function)]"
"WebhookEventSchema — [./api (const)] ≠ [./integration (const)]"
]
}
2 changes: 1 addition & 1 deletion packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,7 @@
"shared/ExpressionInput",
"shared/ExpressionMeta",
"shared/FieldMapping",
"shared/FieldMappingTransform",
"shared/FieldName",
"shared/FlowName",
"shared/HttpMethod",
Expand All @@ -1156,7 +1157,6 @@
"shared/StaticMount",
"shared/SystemIdentifier",
"shared/TemplateExpressionInput",
"shared/TransformType",
"shared/ViewName",
"studio/ActionContribution",
"studio/ActionLocation",
Expand Down
53 changes: 53 additions & 0 deletions packages/spec/src/contracts/sharing-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import type { RecordShareRecipientType, SharingRuleRecipientType } from './sharing-service';
import { ShareRecipientType } from '../security/sharing.zod';

/**
* [#4539] `RecordShareRecipientType` (né `ShareRecipientType`) pins.
*
* The contract type used to be named `ShareRecipientType` while
* `spec/security` exported a zod enum `ShareRecipientType` for a DIFFERENT
* concept (sharing-RULE recipients) with a diverged value set — the #4411
* dual-source trap, worsened by the type≠const kind split. The contract side
* was renamed; these pins keep the two vocabularies honest about what they
* each describe.
*/
describe('Sharing Service Contract — recipient vocabularies (#4539)', () => {
it('RecordShareRecipientType matches the sys_record_share recipient_type select', () => {
// The storage select on SysRecordShare (`@objectstack/plugin-sharing`) is
// the gate on what a row can contain; the contract type mirrors it 1:1.
// `role` — the value the old contract type carried — was never
// persistable there and is NOT a member.
const rowRecipients: RecordShareRecipientType[] = [
'user',
'group',
'position',
'unit_and_subordinates',
'guest',
];
// @ts-expect-error `role` never was a persistable recipient_type value
const notARowRecipient: RecordShareRecipientType = 'role';
expect(rowRecipients).toHaveLength(5);
expect(notARowRecipient).toBe('role');
});

it('security ShareRecipientType is the RULE vocabulary and shares no declaration', () => {
// The authorable rule-recipient enum: SharingRuleRecipientType minus the
// reserved `queue`. Distinct concept, distinct values — `group` / `guest`
// were deliberately removed from it (ADR-0078) while the record-share ROW
// vocabulary keeps persisting them for forward compatibility.
expect(ShareRecipientType.options).toEqual([
'user',
'team',
'position',
'unit_and_subordinates',
'business_unit',
]);
const ruleRecipient: SharingRuleRecipientType = 'queue';
// @ts-expect-error `queue` is reserved to the runtime rule contract — not authorable
const notAuthorable: (typeof ShareRecipientType.options)[number] = 'queue';
expect(ruleRecipient).toBe(notAuthorable);
});
});
24 changes: 19 additions & 5 deletions packages/spec/src/contracts/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@
* The default implementation lives in `@objectstack/plugin-sharing`.
*/

/** Recipient categories — mirrors `ShareRecipientType` in spec/security. */
export type ShareRecipientType =
/**
* Recipient categories a `sys_record_share` ROW may carry — mirrors the
* `recipient_type` select on `SysRecordShare`
* (`@objectstack/plugin-sharing`), which is the storage-side gate on what a
* row can actually contain. Only `user` is enforced (and written) today:
* {@link ISharingService.grant} refuses every other value rather than
* persisting it inert (ADR-0078), and the rule evaluator materialises grants
* as expanded `user` rows. The remaining members are persisted-for-forward-
* compatibility vocabulary, kept in lockstep with the select.
*
* NOT the sharing-RULE recipient vocabulary — that is
* {@link SharingRuleRecipientType} here, whose authorable subset is
* `ShareRecipientType` in `spec/security` (a different concept that shares
* no declaration with this one).
*/
export type RecordShareRecipientType =
| 'user'
| 'group'
| 'role'
| 'position'
| 'unit_and_subordinates'
| 'guest';

Expand All @@ -52,7 +66,7 @@ export interface RecordShare {
id: string;
object_name: string;
record_id: string;
recipient_type: ShareRecipientType;
recipient_type: RecordShareRecipientType;
recipient_id: string;
access_level: ShareAccessLevel;
source: ShareSource;
Expand All @@ -67,7 +81,7 @@ export interface RecordShare {
export interface GrantShareInput {
object: string;
recordId: string;
recipientType?: ShareRecipientType;
recipientType?: RecordShareRecipientType;
recipientId: string;
accessLevel?: ShareAccessLevel;
source?: ShareSource;
Expand Down
42 changes: 31 additions & 11 deletions packages/spec/src/data/type-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import { describe, it, expect } from 'vitest';
import {
canonicalizeSqlType,
suggestFieldType,
suggestFieldTypeForSqlType,
isCompatible,
} from './type-compat';
import { suggestFieldType } from '../shared/suggestions.zod';

describe('canonicalizeSqlType (ADR-0015 §4.6)', () => {
it('strips length/precision parameters', () => {
Expand Down Expand Up @@ -45,20 +46,39 @@ describe('canonicalizeSqlType (ADR-0015 §4.6)', () => {
});
});

describe('suggestFieldType', () => {
describe('suggestFieldTypeForSqlType', () => {
it('suggests sensible defaults per canonical type', () => {
expect(suggestFieldType('varchar(255)')).toBe('text');
expect(suggestFieldType('integer')).toBe('number');
expect(suggestFieldType('numeric(10,2)')).toBe('number');
expect(suggestFieldType('boolean')).toBe('boolean');
expect(suggestFieldType('timestamptz', 'postgres')).toBe('datetime');
expect(suggestFieldType('date')).toBe('date');
expect(suggestFieldType('jsonb', 'postgres')).toBe('json');
expect(suggestFieldType('vector', 'postgres')).toBe('vector');
expect(suggestFieldTypeForSqlType('varchar(255)')).toBe('text');
expect(suggestFieldTypeForSqlType('integer')).toBe('number');
expect(suggestFieldTypeForSqlType('numeric(10,2)')).toBe('number');
expect(suggestFieldTypeForSqlType('boolean')).toBe('boolean');
expect(suggestFieldTypeForSqlType('timestamptz', 'postgres')).toBe('datetime');
expect(suggestFieldTypeForSqlType('date')).toBe('date');
expect(suggestFieldTypeForSqlType('jsonb', 'postgres')).toBe('json');
expect(suggestFieldTypeForSqlType('vector', 'postgres')).toBe('vector');
});

it('returns undefined for unknown types', () => {
expect(suggestFieldType('geometry')).toBeUndefined();
expect(suggestFieldTypeForSqlType('geometry')).toBeUndefined();
});

// #4539: this mapper and shared/suggestions.zod's `suggestFieldType` used to
// SHARE the name `suggestFieldType` while being different functions with
// different signatures, semantics and return types — the worst dual-source
// shape, since a wrong auto-import compiled (`[]` is truthy where
// `undefined` was expected) and misbehaved with no type error. These pins
// encode the divergence that forced the rename; if the two are ever
// reconciled, delete this block deliberately.
it('is NOT the typo-suggester: same input, divergent semantics', () => {
// SQL vocabulary: mapper resolves it, typo-suggester cannot.
expect(suggestFieldTypeForSqlType('varchar(255)')).toBe('text');
expect(suggestFieldType('varchar(255)')).toEqual([]);
// FieldType typo: typo-suggester resolves it, mapper cannot.
expect(suggestFieldTypeForSqlType('text_area')).toBeUndefined();
expect(suggestFieldType('text_area')).toEqual(['textarea']);
// Overlapping input: scalar FieldType vs array of candidates.
expect(suggestFieldTypeForSqlType('int')).toBe('number');
expect(suggestFieldType('int')).toEqual(['number']);
});
});

Expand Down
Loading
Loading