Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4310a6a
docs(created-updated-at-authoring): add timestamp authoring plan
jkomyno May 4, 2026
682714e
feat(sql): add updatedAt timestamp authoring
jkomyno May 4, 2026
ece4e18
fix(sql): address updatedAt review findings
jkomyno May 5, 2026
40462aa
refactor(sql): collapse executionDefault into executionDefaults
jkomyno May 5, 2026
0d45548
refactor(psl): consolidate @updatedAt validation paths and improve di…
jkomyno May 5, 2026
89e15f5
feat(runtime): validate mutation default generators at execution cont…
jkomyno May 5, 2026
40d67af
refactor(sql): centralize timestampNow descriptor and runtime wiring …
jkomyno May 5, 2026
146242c
fix(orm-client): apply @updatedAt to ORM updates and stabilize across…
jkomyno May 5, 2026
14ff454
docs(plan): record Milestone 4 — ORM update wiring and across-rows st…
jkomyno May 5, 2026
f25ab85
docs(plan): pivot timestamp authoring to PSL field-preset surface
jkomyno May 6, 2026
ac8b147
feat(psl): add field-preset dispatch path with temporal namespace
jkomyno May 6, 2026
2f5c88e
feat(psl,authoring): add preset misuse diagnostics and registry colli…
jkomyno May 6, 2026
91178d7
docs(plan): correct RD11 — namespaced-call arg-error rename deferred
jkomyno May 6, 2026
f3b748d
feat(targets): re-namespace timestamp presets under field.temporal
jkomyno May 6, 2026
b9a2caf
feat(psl)!: remove @updatedAt attribute, add migration hint
jkomyno May 6, 2026
889ba98
docs(authoring): update READMEs for temporal preset surface
jkomyno May 6, 2026
6ec547a
refactor(sql,family): share temporal.* preset registration across tar…
jkomyno May 6, 2026
fa2c8d2
refactor(sql,runtime): drop applyOnEmptyUpdate opt-in
jkomyno May 6, 2026
2a01ae5
refactor(sql,authoring): tighten nullable+executionDefaults rejection
jkomyno May 6, 2026
93be243
docs(plan): record YAGNI cuts and temporal preset consolidation
jkomyno May 6, 2026
13e24d0
Merge branch 'main' into feat/created-updated-at-authoring
jkomyno May 7, 2026
94a7ee2
fix(contract,authoring): validate resolved literal defaults before ca…
jkomyno May 7, 2026
c835e8f
refactor(family-sql): isolate runtime generator from control-plane mo…
jkomyno May 7, 2026
dbdb336
test(orm-client): assert Date identity for stableAcrossRows
jkomyno May 7, 2026
58d7f5a
feat(sql): support id-less sql models
jkomyno May 5, 2026
014ea5c
fix(psl): tighten @id and @@id identity validation (todos 067, 068, 0…
jkomyno May 5, 2026
a44bf5a
refactor(psl): collapse primary-key reconciliation and document ORM P…
jkomyno May 5, 2026
d54fcc1
chore(psl): unify entityLabel parameter and hoist model-attribute lab…
jkomyno May 5, 2026
405ea06
fix(psl): reject @@unique and @@index with duplicate fields
jkomyno May 5, 2026
6cba724
fix(ci): stabilize idless model checks
jkomyno May 7, 2026
ba6b20c
Merge remote-tracking branch 'origin/main' into feat/idless-models
jkomyno May 8, 2026
f8dd2df
docs(sql): clarify id-less ORM primary-key fallback
jkomyno May 8, 2026
116bc52
fix(sql): centralize storage constraint validation
jkomyno May 8, 2026
c1d6b15
chore(projects): close out id-less-sql-models
jkomyno May 8, 2026
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
6 changes: 5 additions & 1 deletion docs/architecture docs/subsystems/3. Query Lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,11 @@ const plan = o.user()
- **DML operations** via INSERT, UPDATE, DELETE statements with model-to-column mapping
- **One call → one statement** remains the rule

The ORM is optional and layered on top of the DSL.
The ORM is optional and layered on top of the SQL DSL query-builder lane (`db.sql` / `sql()`).

### Id-less tables and primary-key fallback

`storage.tables.<t>.primaryKey` is optional in the SQL contract. Id-less SQL tables are emittable from PSL (`model X { ... }` without `@id`/`@@id`) and from introspection. This is not a general restriction on ORM collection operations: predicate-based reads and writes such as `where(...).first()`, `where(...).update(...)`, and `where(...).delete()` can target id-less tables because the caller provides the row-matching predicate explicitly. The SQL DSL query-builder lane (`db.sql` / `sql()`) is also safe for the same reason: callers state the table, predicates, and projection directly. The unsafe case is primary-key fallback behavior, such as mutation reloads, default upsert conflict columns, count helpers that currently select the primary-key column before mutating, or future `findUnique`-style APIs. Those helpers must either require an explicit unique predicate or be gated by a per-model capability.

### Shared Collection interface across families

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { EmissionSpi } from '@prisma-next/framework-components/emission';
import { timeouts } from '@prisma-next/test-utils';
import { ifDefined } from '@prisma-next/utils/defined';
import { notOk, ok } from '@prisma-next/utils/result';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@prisma-next/emitter', () => ({
emit: vi.fn(
Expand All @@ -36,6 +36,20 @@ import type { ControlProgressEvent } from '../../src/control-api/types';

const mockEmit = vi.mocked(emitFn);

function createMockEmitResult(): EmitResult {
return {
storageHash: 'test-core-hash',
profileHash: 'test-profile-hash',
contractJson: '{"test": true}',
contractDts: 'export interface Contract {}',
};
}

beforeEach(() => {
mockEmit.mockReset();
mockEmit.mockResolvedValue(createMockEmitResult());
});

function createSourceProvider(
load: ContractSourceProvider['load'] = async () => ok({ test: true } as unknown as Contract),
inputs?: readonly string[],
Expand Down
1 change: 1 addition & 0 deletions packages/1-framework/3-tooling/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
pool: 'forks',
maxWorkers: 1,
isolate: false,
fileParallelism: false,
testTimeout: timeouts.vitestPackageDefault,
hookTimeout: timeouts.vitestPackageDefault,
setupFiles: ['./test/setup.ts'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,70 +171,74 @@ describe('emitter round-trip', () => {
timeouts.typeScriptCompilation,
);

it('round-trip with nullable fields', async () => {
const ir = createTestContract({
storage: {
tables: {
user: {
columns: {
id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
email: { codecId: 'pg/text@1', nativeType: 'text', nullable: true },
name: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
it(
'round-trip with nullable fields',
async () => {
const ir = createTestContract({
storage: {
tables: {
user: {
columns: {
id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
email: { codecId: 'pg/text@1', nativeType: 'text', nullable: true },
name: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
},
primaryKey: { columns: ['id'] },
uniques: [],
indexes: [],
foreignKeys: [],
},
primaryKey: { columns: ['id'] },
uniques: [],
indexes: [],
foreignKeys: [],
},
},
},
extensionPacks: {
postgres: { version: '0.0.1' },
pg: {},
},
});
extensionPacks: {
postgres: { version: '0.0.1' },
pg: {},
},
});

const codecTypeImports: TypesImportSpec[] = [];
const operationTypeImports: TypesImportSpec[] = [];
const extensionIds = ['postgres', 'pg'];
const options: EmitStackInput = {
codecTypeImports,
operationTypeImports,
extensionIds,
};
const codecTypeImports: TypesImportSpec[] = [];
const operationTypeImports: TypesImportSpec[] = [];
const extensionIds = ['postgres', 'pg'];
const options: EmitStackInput = {
codecTypeImports,
operationTypeImports,
extensionIds,
};

const result1 = await emit(ir, options, mockSqlHook);
const contractJson1 = JSON.parse(result1.contractJson) as Record<string, unknown>;
const result1 = await emit(ir, options, mockSqlHook);
const contractJson1 = JSON.parse(result1.contractJson) as Record<string, unknown>;

const ir2 = createTestContract({
targetFamily: contractJson1['targetFamily'] as string,
target: contractJson1['target'] as string,
roots: contractJson1['roots'] as Record<string, string>,
models: contractJson1['models'] as Record<string, unknown>,
storage: contractJson1['storage'] as Record<string, unknown>,
extensionPacks: contractJson1['extensionPacks'] as Record<string, unknown>,
capabilities:
(contractJson1['capabilities'] as Record<string, Record<string, boolean>>) || {},
meta: (contractJson1['meta'] as Record<string, unknown>) || {},
});
const ir2 = createTestContract({
targetFamily: contractJson1['targetFamily'] as string,
target: contractJson1['target'] as string,
roots: contractJson1['roots'] as Record<string, string>,
models: contractJson1['models'] as Record<string, unknown>,
storage: contractJson1['storage'] as Record<string, unknown>,
extensionPacks: contractJson1['extensionPacks'] as Record<string, unknown>,
capabilities:
(contractJson1['capabilities'] as Record<string, Record<string, boolean>>) || {},
meta: (contractJson1['meta'] as Record<string, unknown>) || {},
});

const result2 = await emit(ir2, options, mockSqlHook);
const result2 = await emit(ir2, options, mockSqlHook);

expect(result1.contractJson).toBe(result2.contractJson);
expect(result1.storageHash).toBe(result2.storageHash);
expect(result1.contractJson).toBe(result2.contractJson);
expect(result1.storageHash).toBe(result2.storageHash);

const parsed2 = JSON.parse(result2.contractJson) as Record<string, unknown>;
const storage = parsed2['storage'] as Record<string, unknown>;
const tables = storage['tables'] as Record<string, unknown>;
const user = tables['user'] as Record<string, unknown>;
const columns = user['columns'] as Record<string, unknown>;
const id = columns['id'] as Record<string, unknown>;
const email = columns['email'] as Record<string, unknown>;
const name = columns['name'] as Record<string, unknown>;
expect(id['nullable']).toBe(false);
expect(email['nullable']).toBe(true);
expect(name['nullable']).toBe(false);
});
const parsed2 = JSON.parse(result2.contractJson) as Record<string, unknown>;
const storage = parsed2['storage'] as Record<string, unknown>;
const tables = storage['tables'] as Record<string, unknown>;
const user = tables['user'] as Record<string, unknown>;
const columns = user['columns'] as Record<string, unknown>;
const id = columns['id'] as Record<string, unknown>;
const email = columns['email'] as Record<string, unknown>;
const name = columns['name'] as Record<string, unknown>;
expect(id['nullable']).toBe(false);
expect(email['nullable']).toBe(true);
expect(name['nullable']).toBe(false);
},
timeouts.typeScriptCompilation,
);

it('round-trip with capabilities', async () => {
const ir = createTestContract({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export function prismaVitePlugin(
log(` → ${result.files.json}`, 'debug');
log(` → ${result.files.dts}`, 'debug');

if (server) {
server.moduleGraph.onFileChange(result.files.json);
server.moduleGraph.onFileChange(result.files.dts);
}

if (server && !hasQueuedEmit) {
server.ws.send({ type: 'full-reload' });
} else if (hasQueuedEmit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ function createMockServer() {
ssrLoadModule: vi.fn().mockResolvedValue({}),
moduleGraph: {
getModuleById: vi.fn().mockReturnValue(null),
onFileChange: vi.fn(),
},
};
}
Expand Down Expand Up @@ -415,6 +416,22 @@ describe('prismaVitePlugin', () => {
);
});

it('invalidates emitted artifacts after successful emit', async () => {
const plugin = prismaVitePlugin('prisma-next.config.ts', { logLevel: 'silent' });
const mockServer = createMockServer();

const configResolved = plugin.configResolved as unknown as (config: { root: string }) => void;
configResolved({ root: '/project' });

const configureServer = plugin.configureServer as unknown as (
server: ReturnType<typeof createMockServer>,
) => Promise<void>;
await configureServer(mockServer);

expect(mockServer.moduleGraph.onFileChange).toHaveBeenCalledWith('/out/contract.json');
expect(mockServer.moduleGraph.onFileChange).toHaveBeenCalledWith('/out/contract.d.ts');
});

it('loads config once on startup before the initial emit', async () => {
const plugin = prismaVitePlugin('prisma-next.config.ts', { logLevel: 'silent' });
const mockServer = createMockServer();
Expand Down
43 changes: 43 additions & 0 deletions packages/2-sql/1-core/contract/src/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function findDuplicateValue(values: readonly string[]): string | undefined {
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
return value;
}
seen.add(value);
}
return undefined;
}

function isContractFieldType(value: unknown): boolean {
if (!isPlainRecord(value)) return false;
const kind = value['kind'];
Expand Down Expand Up @@ -293,6 +304,8 @@ export function validateSqlContract<T extends Contract<SqlStorage>>(value: unkno
* Currently checks:
* - duplicate named primary key / unique / index / foreign key objects within a table
* - duplicate unique, index, or foreign key declarations within a table
* - duplicate columns within primary key / unique / index definitions
* - nullable columns in primary key definitions
* - `setNull` referential action on a non-nullable FK column (would fail at runtime)
* - `setDefault` referential action on a non-nullable FK column without a DEFAULT (would fail at runtime)
*/
Expand Down Expand Up @@ -325,8 +338,33 @@ export function validateStorageSemantics(storage: SqlStorage): string[] {
}
}

if (table.primaryKey) {
const duplicateColumn = findDuplicateValue(table.primaryKey.columns);
if (duplicateColumn !== undefined) {
errors.push(
`Table "${tableName}": primary key contains duplicate column "${duplicateColumn}"`,
);
}

for (const columnName of table.primaryKey.columns) {
const column = table.columns[columnName];
if (column?.nullable === true) {
errors.push(
`Table "${tableName}": primary key column "${columnName}" is nullable; primary key columns must be NOT NULL`,
);
}
}
}

const seenUniqueDefinitions = new Set<string>();
for (const unique of table.uniques) {
const duplicateColumn = findDuplicateValue(unique.columns);
if (duplicateColumn !== undefined) {
errors.push(
`Table "${tableName}": unique constraint contains duplicate column "${duplicateColumn}"`,
);
}

const signature = JSON.stringify({ columns: unique.columns });
if (seenUniqueDefinitions.has(signature)) {
errors.push(
Expand All @@ -339,6 +377,11 @@ export function validateStorageSemantics(storage: SqlStorage): string[] {

const seenIndexDefinitions = new Set<string>();
for (const index of table.indexes) {
const duplicateColumn = findDuplicateValue(index.columns);
if (duplicateColumn !== undefined) {
errors.push(`Table "${tableName}": index contains duplicate column "${duplicateColumn}"`);
}

const signature = JSON.stringify({
columns: index.columns,
using: index.using ?? null,
Expand Down
51 changes: 51 additions & 0 deletions packages/2-sql/1-core/contract/test/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,57 @@ describe('SQL contract validators', () => {
expect(errors[1]).toContain('duplicate index definition');
});

it('rejects duplicate columns inside key, unique, and index definitions', () => {
const s = createContract<SqlStorage>({
storage: {
tables: {
user: table(
{
id: col('int4', 'pg/int4@1'),
email: col('text', 'pg/text@1'),
},
{
pk: pk('id', 'id'),
uniques: [unique('email', 'email')],
indexes: [index('email', 'email')],
},
),
},
},
}).storage;

const errors = validateStorageSemantics(s);
expect(errors).toHaveLength(3);
expect(errors[0]).toContain('primary key');
expect(errors[0]).toContain('duplicate column "id"');
expect(errors[1]).toContain('unique constraint');
expect(errors[1]).toContain('duplicate column "email"');
expect(errors[2]).toContain('index');
expect(errors[2]).toContain('duplicate column "email"');
});

it('rejects nullable primary-key columns', () => {
const s = createContract<SqlStorage>({
storage: {
tables: {
user: table(
{
id: col('int4', 'pg/int4@1', true),
},
{
pk: pk('id'),
},
),
},
},
}).storage;

const errors = validateStorageSemantics(s);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('primary key column "id"');
expect(errors[0]).toContain('NOT NULL');
});

it('rejects duplicate foreign key definitions within the same table', () => {
const s = createContract<SqlStorage>({
storage: {
Expand Down
Loading
Loading