rwsdk 1.0.0-beta.27-test.20251116215153 → 1.0.0-beta.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,14 @@
1
1
  import { CheckConstraintNode, Expression, ForeignKeyConstraintBuilder, PrimaryKeyConstraintNode, sql, UniqueConstraintNode } from "kysely";
2
2
  import { AddColumnOp, AlterColumnOp, AlterOperation, DropColumnOp, ExecutedBuilder, ModifyColumnOp, RenameColumnOp, SqlToTsType } from "../utils";
3
3
  import { AlterColumnBuilderCallback } from "./alterColumn";
4
- import { ColumnDefinitionBuilder } from "./columnDefinition";
4
+ import { ColumnDefinitionBuilder, ColumnDescriptor } from "./columnDefinition";
5
5
  type DataTypeExpression = string | typeof sql;
6
+ type InitialDescriptor<TType> = {
7
+ tsType: TType;
8
+ isNullable: true;
9
+ hasDefault: false;
10
+ isAutoIncrement: false;
11
+ };
6
12
  interface CheckConstraintBuilder {
7
13
  $call<T>(func: (qb: this) => T): T;
8
14
  toOperationNode(): CheckConstraintNode;
@@ -31,14 +37,18 @@ export interface AlterTableBuilder<TName extends string, TOps extends AlterOpera
31
37
  readonly __renamedFrom: TName;
32
38
  };
33
39
  setSchema(newSchema: string): AlterTableBuilder<TName, TOps>;
34
- addColumn<K extends string, T extends DataTypeExpression>(name: K, type: T, build?: (col: ColumnDefinitionBuilder<SqlToTsType<T>>) => ColumnDefinitionBuilder<SqlToTsType<T>>): AlterTableBuilder<TName, [...TOps, AddColumnOp<K, T>]>;
40
+ addColumn<K extends string, T extends DataTypeExpression>(name: K, type: T): AlterTableBuilder<TName, [
41
+ ...TOps,
42
+ AddColumnOp<K, T, InitialDescriptor<SqlToTsType<T>>>
43
+ ]>;
44
+ addColumn<K extends string, T extends DataTypeExpression, TDescriptor extends ColumnDescriptor>(name: K, type: T, build: (col: ColumnDefinitionBuilder<InitialDescriptor<SqlToTsType<T>>>) => ColumnDefinitionBuilder<TDescriptor>): AlterTableBuilder<TName, [...TOps, AddColumnOp<K, T, TDescriptor>]>;
35
45
  dropColumn<K extends string>(name: K): AlterTableBuilder<TName, [...TOps, DropColumnOp<K>]>;
36
46
  renameColumn<KFrom extends string, KTo extends string>(from: KFrom, to: KTo): AlterTableBuilder<TName, [...TOps, RenameColumnOp<KFrom, KTo>]>;
37
47
  alterColumn<K extends string, const TCallback extends AlterColumnBuilderCallback>(column: K, alteration: TCallback): AlterTableBuilder<TName, [
38
48
  ...TOps,
39
49
  AlterColumnOp<K, ReturnType<TCallback>["__alteration"]>
40
50
  ]>;
41
- modifyColumn<K extends string, T extends DataTypeExpression>(column: K, type: T, build?: (col: ColumnDefinitionBuilder<SqlToTsType<T>>) => ColumnDefinitionBuilder<SqlToTsType<T>>): AlterTableBuilder<TName, [...TOps, ModifyColumnOp<K, T>]>;
51
+ modifyColumn<K extends string, T extends DataTypeExpression, TDescriptor extends ColumnDescriptor>(column: K, type: T, build?: (col: ColumnDefinitionBuilder<InitialDescriptor<SqlToTsType<T>>>) => ColumnDefinitionBuilder<TDescriptor>): AlterTableBuilder<TName, [...TOps, ModifyColumnOp<K, T, TDescriptor>]>;
42
52
  addUniqueConstraint(constraintName: string, columns: string[], build?: (builder: UniqueConstraintBuilder) => UniqueConstraintBuilder): AlterTableBuilder<TName, TOps>;
43
53
  addPrimaryKeyConstraint(constraintName: string, columns: string[], build?: (builder: PrimaryKeyConstraintBuilder) => PrimaryKeyConstraintBuilder): AlterTableBuilder<TName, TOps>;
44
54
  addCheckConstraint(constraintName: string, checkExpression: Expression<any>, build?: (builder: CheckConstraintBuilder) => CheckConstraintBuilder): AlterTableBuilder<TName, TOps>;
@@ -1,25 +1,39 @@
1
1
  import { ColumnDefinitionNode, Expression, sql } from "kysely";
2
2
  type DefaultValueExpression = string | number | boolean | null | ReturnType<typeof sql>;
3
- export interface ColumnDefinitionBuilder<TType> {
4
- autoIncrement(): ColumnDefinitionBuilder<TType>;
5
- identity(): ColumnDefinitionBuilder<TType>;
6
- primaryKey(): ColumnDefinitionBuilder<TType>;
7
- references(ref: string): ColumnDefinitionBuilder<TType>;
8
- onDelete(onDelete: "no action" | "restrict" | "cascade" | "set null" | "set default"): ColumnDefinitionBuilder<TType>;
9
- onUpdate(onUpdate: "no action" | "restrict" | "cascade" | "set null" | "set default"): ColumnDefinitionBuilder<TType>;
10
- unique(): ColumnDefinitionBuilder<TType>;
11
- notNull(): ColumnDefinitionBuilder<TType>;
12
- unsigned(): ColumnDefinitionBuilder<TType>;
13
- defaultTo(value: DefaultValueExpression): ColumnDefinitionBuilder<TType>;
14
- check(expression: Expression<any>): ColumnDefinitionBuilder<TType>;
15
- generatedAlwaysAs(expression: Expression<any>): ColumnDefinitionBuilder<TType>;
16
- generatedAlwaysAsIdentity(): ColumnDefinitionBuilder<TType>;
17
- generatedByDefaultAsIdentity(): ColumnDefinitionBuilder<TType>;
18
- stored(): ColumnDefinitionBuilder<TType>;
19
- modifyFront(modifier: Expression<any>): ColumnDefinitionBuilder<TType>;
20
- nullsNotDistinct(): ColumnDefinitionBuilder<TType>;
21
- ifNotExists(): ColumnDefinitionBuilder<TType>;
22
- modifyEnd(modifier: Expression<any>): ColumnDefinitionBuilder<TType>;
3
+ export type ColumnDescriptor = {
4
+ tsType: any;
5
+ isNullable: boolean;
6
+ hasDefault: boolean;
7
+ isAutoIncrement: boolean;
8
+ };
9
+ export interface ColumnDefinitionBuilder<TDescriptor extends ColumnDescriptor> {
10
+ autoIncrement(): ColumnDefinitionBuilder<{
11
+ [K in keyof TDescriptor]: K extends "isAutoIncrement" ? true : TDescriptor[K];
12
+ }>;
13
+ identity(): ColumnDefinitionBuilder<TDescriptor>;
14
+ primaryKey(): ColumnDefinitionBuilder<{
15
+ [K in keyof TDescriptor]: K extends "isNullable" ? false : TDescriptor[K];
16
+ }>;
17
+ references(ref: string): ColumnDefinitionBuilder<TDescriptor>;
18
+ onDelete(onDelete: "no action" | "restrict" | "cascade" | "set null" | "set default"): ColumnDefinitionBuilder<TDescriptor>;
19
+ onUpdate(onUpdate: "no action" | "restrict" | "cascade" | "set null" | "set default"): ColumnDefinitionBuilder<TDescriptor>;
20
+ unique(): ColumnDefinitionBuilder<TDescriptor>;
21
+ notNull(): ColumnDefinitionBuilder<{
22
+ [K in keyof TDescriptor]: K extends "isNullable" ? false : TDescriptor[K];
23
+ }>;
24
+ unsigned(): ColumnDefinitionBuilder<TDescriptor>;
25
+ defaultTo(value: DefaultValueExpression): ColumnDefinitionBuilder<{
26
+ [K in keyof TDescriptor]: K extends "isNullable" ? false : K extends "hasDefault" ? true : TDescriptor[K];
27
+ }>;
28
+ check(expression: Expression<any>): ColumnDefinitionBuilder<TDescriptor>;
29
+ generatedAlwaysAs(expression: Expression<any>): ColumnDefinitionBuilder<TDescriptor>;
30
+ generatedAlwaysAsIdentity(): ColumnDefinitionBuilder<TDescriptor>;
31
+ generatedByDefaultAsIdentity(): ColumnDefinitionBuilder<TDescriptor>;
32
+ stored(): ColumnDefinitionBuilder<TDescriptor>;
33
+ modifyFront(modifier: Expression<any>): ColumnDefinitionBuilder<TDescriptor>;
34
+ nullsNotDistinct(): ColumnDefinitionBuilder<TDescriptor>;
35
+ ifNotExists(): ColumnDefinitionBuilder<TDescriptor>;
36
+ modifyEnd(modifier: Expression<any>): ColumnDefinitionBuilder<TDescriptor>;
23
37
  $call<T>(func: (qb: this) => T): T;
24
38
  toOperationNode(): ColumnDefinitionNode;
25
39
  }
@@ -1,6 +1,6 @@
1
1
  import { CheckConstraintNode, CompiledQuery, CreateTableNode, Expression, ForeignKeyConstraintBuilder, PrimaryKeyConstraintNode, UniqueConstraintNode } from "kysely";
2
2
  import { ExecutedBuilder, Prettify, SqlToTsType } from "../utils";
3
- import { ColumnDefinitionBuilder } from "./columnDefinition";
3
+ import { ColumnDefinitionBuilder, ColumnDescriptor } from "./columnDefinition";
4
4
  interface CheckConstraintBuilder {
5
5
  $call<T>(func: (qb: this) => T): T;
6
6
  toOperationNode(): CheckConstraintNode;
@@ -22,13 +22,20 @@ interface PrimaryKeyConstraintBuilder {
22
22
  $call<T>(func: (qb: this) => T): T;
23
23
  toOperationNode(): PrimaryKeyConstraintNode;
24
24
  }
25
+ type InitialDescriptor<TType> = {
26
+ tsType: TType;
27
+ isNullable: true;
28
+ hasDefault: false;
29
+ isAutoIncrement: false;
30
+ };
25
31
  export interface CreateTableBuilder<TName extends string, TSchema extends Record<string, any> = {}> {
26
32
  readonly __tableName: TName;
27
33
  readonly __addedColumns: TSchema;
28
34
  temporary(): CreateTableBuilder<TName, TSchema>;
29
35
  onCommit(onCommit: "preserve rows" | "delete rows" | "drop"): CreateTableBuilder<TName, TSchema>;
30
36
  ifNotExists(): CreateTableBuilder<TName, TSchema>;
31
- addColumn<K extends string, T extends string>(name: K, type: T, build?: (col: ColumnDefinitionBuilder<SqlToTsType<T>>) => ColumnDefinitionBuilder<SqlToTsType<T>>): CreateTableBuilder<TName, Prettify<(TSchema extends Record<string, any> ? TSchema : {}) & Record<K, SqlToTsType<T>>>>;
37
+ addColumn<K extends string, T extends string>(name: K, type: T): CreateTableBuilder<TName, Prettify<(TSchema extends Record<string, any> ? TSchema : {}) & Record<K, InitialDescriptor<SqlToTsType<T>>>>>;
38
+ addColumn<K extends string, T extends string, TDescriptor extends ColumnDescriptor>(name: K, type: T, build: (col: ColumnDefinitionBuilder<InitialDescriptor<SqlToTsType<T>>>) => ColumnDefinitionBuilder<TDescriptor>): CreateTableBuilder<TName, Prettify<(TSchema extends Record<string, any> ? TSchema : {}) & Record<K, TDescriptor>>>;
32
39
  addUniqueConstraint(constraintName: string, columns: (keyof TSchema)[], build?: (builder: UniqueConstraintBuilder) => UniqueConstraintBuilder): CreateTableBuilder<TName, TSchema>;
33
40
  addPrimaryKeyConstraint(constraintName: string, columns: (keyof TSchema)[], build?: (builder: PrimaryKeyConstraintBuilder) => PrimaryKeyConstraintBuilder): CreateTableBuilder<TName, TSchema>;
34
41
  addCheckConstraint(constraintName: string, checkExpression: Expression<any>, build?: (builder: CheckConstraintBuilder) => CheckConstraintBuilder): CreateTableBuilder<TName, TSchema>;
@@ -1,9 +1,10 @@
1
- import { Kysely } from "kysely";
1
+ import { Generated, Kysely } from "kysely";
2
2
  import { AlterTableBuilder } from "./builders/alterTable";
3
3
  import { CreateTableBuilder } from "./builders/createTable";
4
4
  import { DropTableBuilder } from "./builders/dropTable";
5
5
  import { SchemaBuilder } from "./builders/schema";
6
6
  import { ExecutedBuilder, Prettify, ProcessAlteredTable, UnionToTuple } from "./utils";
7
+ import { ColumnDescriptor } from "./builders/columnDefinition";
7
8
  export interface InferenceBuilder {
8
9
  schema: SchemaBuilder;
9
10
  }
@@ -23,5 +24,18 @@ type ApplyBuilders<TSchema, TBuildersTuple> = TBuildersTuple extends [
23
24
  ...infer TRest
24
25
  ] ? ApplyBuilders<ApplyBuilder<TSchema, THead>, TRest> : TSchema;
25
26
  type ProcessMigrations<TMigrations extends Migrations, TKeys, TSchema = {}> = TKeys extends [infer THeadKey, ...infer TRestKeys] ? THeadKey extends keyof TMigrations ? ProcessMigrations<TMigrations, TRestKeys, ApplyBuilders<TSchema, UnionToTuple<BuildersFromMigration<TMigrations[THeadKey]>>>> : TSchema : TSchema;
26
- export type Database<TMigrations extends Migrations = Migrations> = ProcessMigrations<TMigrations, UnionToTuple<keyof TMigrations>>;
27
+ type TableToSelectType<TTable> = Prettify<{
28
+ [K in keyof TTable]: TTable[K] extends ColumnDescriptor ? TTable[K]["isNullable"] extends true ? TTable[K]["tsType"] | null : TTable[K]["tsType"] : TTable[K];
29
+ }>;
30
+ type TableToKyselySchema<TTable> = Prettify<{
31
+ [K in keyof TTable]: TTable[K] extends ColumnDescriptor ? TTable[K]["hasDefault"] extends true ? Generated<TTable[K]["isNullable"] extends true ? TTable[K]["tsType"] | null : TTable[K]["tsType"]> : TTable[K]["isAutoIncrement"] extends true ? Generated<TTable[K]["tsType"]> : TTable[K]["isNullable"] extends true ? TTable[K]["tsType"] | null : TTable[K]["tsType"] : TTable[K];
32
+ }>;
33
+ type DatabaseWithDescriptors<TMigrations extends Migrations = Migrations> = ProcessMigrations<TMigrations, UnionToTuple<keyof TMigrations>>;
34
+ export type Database<TMigrations extends Migrations = Migrations> = Prettify<{
35
+ [K in keyof DatabaseWithDescriptors<TMigrations>]: TableToSelectType<DatabaseWithDescriptors<TMigrations>[K]>;
36
+ } & {
37
+ __kyselySchema: {
38
+ [K in keyof DatabaseWithDescriptors<TMigrations>]: TableToKyselySchema<DatabaseWithDescriptors<TMigrations>[K]>;
39
+ };
40
+ }>;
27
41
  export {};
@@ -97,7 +97,7 @@
97
97
  },
98
98
  },
99
99
  };
100
- //(_test: Expect<Equal<Actual, Expected>>) => {};
100
+ //(_test: ExpectDb<Actual, Expected>) => {};
101
101
  };
102
102
  (_it = "alterTable addUniqueConstraint") => {
103
103
  const migrations = {
@@ -123,7 +123,7 @@
123
123
  },
124
124
  },
125
125
  };
126
- //(_test: Expect<Equal<Actual, Expected>>) => {};
126
+ //(_test: ExpectDb<Actual, Expected>) => {};
127
127
  };
128
128
  (_it = "alterTable drop column") => {
129
129
  const migrations = {
@@ -153,7 +153,7 @@
153
153
  up: async (db) => [
154
154
  await db.schema
155
155
  .createTable("users")
156
- .addColumn("id", "integer")
156
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
157
157
  .execute(),
158
158
  ],
159
159
  },
@@ -242,7 +242,7 @@
242
242
  return [
243
243
  await db.schema
244
244
  .createTable("users")
245
- .addColumn("id", "integer")
245
+ .addColumn("id", "integer", (col) => col.notNull())
246
246
  .execute(),
247
247
  ];
248
248
  },
@@ -271,7 +271,7 @@
271
271
  return [
272
272
  await db.schema
273
273
  .createTable("users")
274
- .addColumn("id", "integer")
274
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
275
275
  .execute(),
276
276
  ];
277
277
  },
@@ -357,4 +357,79 @@
357
357
  };
358
358
  (_test) => { };
359
359
  };
360
+ (_it = "alterTable addColumn with notNull") => {
361
+ const migrations = {
362
+ "0": {
363
+ async up(db) {
364
+ return [
365
+ await db.schema
366
+ .createTable("users")
367
+ .addColumn("id", "integer", (col) => col.primaryKey())
368
+ .execute(),
369
+ ];
370
+ },
371
+ },
372
+ "1": {
373
+ async up(db) {
374
+ return [
375
+ await db.schema
376
+ .alterTable("users")
377
+ .addColumn("email", "text", (col) => col.notNull())
378
+ .execute(),
379
+ ];
380
+ },
381
+ },
382
+ };
383
+ (_test) => { };
384
+ };
385
+ (_it = "alterTable modifyColumn with notNull") => {
386
+ const migrations = {
387
+ "0": {
388
+ async up(db) {
389
+ return [
390
+ await db.schema
391
+ .createTable("products")
392
+ .addColumn("price", "real")
393
+ .execute(),
394
+ ];
395
+ },
396
+ },
397
+ "1": {
398
+ async up(db) {
399
+ return [
400
+ await db.schema
401
+ .alterTable("products")
402
+ .modifyColumn("price", "real", (col) => col.notNull())
403
+ .execute(),
404
+ ];
405
+ },
406
+ },
407
+ };
408
+ (_test) => { };
409
+ };
410
+ (_it = "alterTable modifyColumn nullable to non-nullable") => {
411
+ const migrations = {
412
+ "0": {
413
+ async up(db) {
414
+ return [
415
+ await db.schema
416
+ .createTable("orders")
417
+ .addColumn("status", "text")
418
+ .execute(),
419
+ ];
420
+ },
421
+ },
422
+ "1": {
423
+ async up(db) {
424
+ return [
425
+ await db.schema
426
+ .alterTable("orders")
427
+ .modifyColumn("status", "text", (col) => col.notNull().defaultTo("pending"))
428
+ .execute(),
429
+ ];
430
+ },
431
+ },
432
+ };
433
+ (_test) => { };
434
+ };
360
435
  export {};
@@ -22,9 +22,11 @@ import { sql } from "kysely";
22
22
  await db.schema
23
23
  .createTable("users")
24
24
  .addColumn("username", "text", (col) => col.notNull())
25
- .addColumn("age", "integer", (col) => col.defaultTo(18))
26
25
  .addColumn("active", "boolean", (col) => col.defaultTo(true))
27
26
  .addColumn("anotherBoolean", "boolean", (col) => col.defaultTo(sql `true`))
27
+ .addColumn("email", "text", (col) => col)
28
+ .addColumn("favoriteColor", "text", (col) => col.unique())
29
+ .addColumn("name", "text", (col) => col.defaultTo("John Doe"))
28
30
  .execute(),
29
31
  ];
30
32
  },
@@ -32,3 +34,102 @@ import { sql } from "kysely";
32
34
  };
33
35
  (_test) => { };
34
36
  };
37
+ (_it = "createTable column without callback is nullable") => {
38
+ const migrations = {
39
+ "001_init": {
40
+ async up(db) {
41
+ return [
42
+ await db.schema
43
+ .createTable("posts")
44
+ .addColumn("title", "text")
45
+ .addColumn("body", "text")
46
+ .execute(),
47
+ ];
48
+ },
49
+ },
50
+ };
51
+ (_test) => { };
52
+ };
53
+ (_it = "createTable with primaryKey is non-nullable") => {
54
+ const migrations = {
55
+ "001_init": {
56
+ async up(db) {
57
+ return [
58
+ await db.schema
59
+ .createTable("users")
60
+ .addColumn("id", "integer", (col) => col.primaryKey())
61
+ .addColumn("email", "text", (col) => col.notNull())
62
+ .execute(),
63
+ ];
64
+ },
65
+ },
66
+ };
67
+ (_test) => { };
68
+ };
69
+ (_it = "createTable with unique but no notNull is nullable") => {
70
+ const migrations = {
71
+ "001_init": {
72
+ async up(db) {
73
+ return [
74
+ await db.schema
75
+ .createTable("products")
76
+ .addColumn("sku", "text", (col) => col.unique())
77
+ .addColumn("name", "text", (col) => col)
78
+ .execute(),
79
+ ];
80
+ },
81
+ },
82
+ };
83
+ (_test) => { };
84
+ };
85
+ (_it = "defaultTo makes columns non-nullable in Database type") => {
86
+ const migrations = {
87
+ "001_init": {
88
+ async up(db) {
89
+ return [
90
+ await db.schema
91
+ .createTable("users")
92
+ .addColumn("status", "text", (col) => col.defaultTo("active"))
93
+ .addColumn("count", "integer", (col) => col.defaultTo(0))
94
+ .execute(),
95
+ ];
96
+ },
97
+ },
98
+ };
99
+ (_test) => { };
100
+ };
101
+ // --- Insert/Update Type Tests ---
102
+ (_it = "makes autoIncrement columns optional on insert") => {
103
+ const migrations = {
104
+ "001_init": {
105
+ async up(db) {
106
+ return [
107
+ await db.schema
108
+ .createTable("users")
109
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
110
+ .addColumn("username", "text", (col) => col.notNull())
111
+ .execute(),
112
+ ];
113
+ },
114
+ },
115
+ };
116
+ const db = {};
117
+ db.insertInto("users").values({ username: "test" });
118
+ };
119
+ (_it = "makes defaultTo columns optional on insert") => {
120
+ const migrations = {
121
+ "001_init": {
122
+ async up(db) {
123
+ return [
124
+ await db.schema
125
+ .createTable("users")
126
+ .addColumn("username", "text", (col) => col.notNull())
127
+ .addColumn("status", "text", (col) => col.notNull().defaultTo("active"))
128
+ .execute(),
129
+ ];
130
+ },
131
+ },
132
+ };
133
+ const db = {};
134
+ db.insertInto("users").values({ username: "test" });
135
+ };
@@ -1,2 +1,3 @@
1
1
  export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
2
2
  export type Expect<T extends true> = T;
3
+ export type OmitInternals<T> = Omit<T, "__kyselySchema">;
@@ -1,9 +1,11 @@
1
1
  import { sql } from "kysely";
2
+ import { ColumnDescriptor } from "./builders/columnDefinition";
2
3
  type DataTypeExpression = string | typeof sql;
3
- export type AddColumnOp<K extends string, T extends DataTypeExpression> = {
4
+ export type AddColumnOp<K extends string, T extends DataTypeExpression, TDescriptor extends ColumnDescriptor> = {
4
5
  op: "addColumn";
5
6
  name: K;
6
7
  type: T;
8
+ descriptor: TDescriptor;
7
9
  };
8
10
  export type DropColumnOp<K extends string> = {
9
11
  op: "dropColumn";
@@ -14,10 +16,11 @@ export type RenameColumnOp<KFrom extends string, KTo extends string> = {
14
16
  from: KFrom;
15
17
  to: KTo;
16
18
  };
17
- export type ModifyColumnOp<K extends string, T extends DataTypeExpression> = {
19
+ export type ModifyColumnOp<K extends string, T extends DataTypeExpression, TDescriptor extends ColumnDescriptor> = {
18
20
  op: "modifyColumn";
19
21
  name: K;
20
22
  type: T;
23
+ descriptor: TDescriptor;
21
24
  };
22
25
  export type Alteration = {
23
26
  kind: "setDataType";
@@ -37,7 +40,7 @@ export type AlterColumnOp<K extends string, TAlteration extends Alteration> = {
37
40
  name: K;
38
41
  alteration: TAlteration;
39
42
  };
40
- export type AlterOperation = AddColumnOp<any, any> | DropColumnOp<any> | RenameColumnOp<any, any> | AlterColumnOp<any, any> | ModifyColumnOp<any, any>;
43
+ export type AlterOperation = AddColumnOp<any, any, any> | DropColumnOp<any> | RenameColumnOp<any, any> | AlterColumnOp<any, any> | ModifyColumnOp<any, any, any>;
41
44
  export type SqlToTsType<T extends string | typeof sql> = T extends "text" ? string : T extends "integer" ? number : T extends "blob" ? Uint8Array : T extends "real" ? number : T extends "boolean" ? boolean : T extends typeof sql ? any : never;
42
45
  export type Prettify<T> = {
43
46
  [K in keyof T]: T[K];
@@ -57,17 +60,64 @@ export type Cast<A, B> = A extends B ? A : B;
57
60
  /**
58
61
  * Applies a single alteration operation to a schema.
59
62
  */
60
- type ApplyOp<TSchema, THeadOp> = THeadOp extends AddColumnOp<infer K, infer T> ? Prettify<TSchema & {
61
- [P in K]: SqlToTsType<T>;
63
+ type ApplyOp<TSchema, THeadOp> = THeadOp extends AddColumnOp<infer K, any, infer TDescriptor> ? Prettify<TSchema & {
64
+ [P in K]: TDescriptor;
62
65
  }> : THeadOp extends DropColumnOp<infer K> ? Omit<TSchema, K> : THeadOp extends RenameColumnOp<infer KFrom, infer KTo> ? KFrom extends keyof TSchema ? Prettify<Omit<TSchema, KFrom> & {
63
66
  [P in KTo]: TSchema[KFrom];
64
- }> : TSchema : THeadOp extends AlterColumnOp<infer K, infer TAlt> ? TAlt extends {
67
+ }> : TSchema : THeadOp extends AlterColumnOp<infer K, infer TAlt> ? K extends keyof TSchema ? TAlt extends {
65
68
  kind: "setDataType";
66
69
  dataType: infer DT extends string;
67
70
  } ? Prettify<Omit<TSchema, K> & {
68
- [P in K]: SqlToTsType<DT>;
69
- }> : TSchema : THeadOp extends ModifyColumnOp<infer K, infer T> ? Prettify<Omit<TSchema, K> & {
70
- [P in K]: SqlToTsType<T>;
71
+ [P in K]: {
72
+ tsType: SqlToTsType<DT>;
73
+ isNullable: TSchema[K] extends {
74
+ isNullable: infer N;
75
+ } ? N : true;
76
+ hasDefault: TSchema[K] extends {
77
+ hasDefault: infer D;
78
+ } ? D : false;
79
+ isAutoIncrement: TSchema[K] extends {
80
+ isAutoIncrement: infer A;
81
+ } ? A : false;
82
+ };
83
+ }> : TAlt extends {
84
+ kind: "setDefault";
85
+ } ? Prettify<Omit<TSchema, K> & {
86
+ [P in K]: TSchema[K] extends ColumnDescriptor ? {
87
+ tsType: TSchema[K]["tsType"];
88
+ isNullable: false;
89
+ hasDefault: true;
90
+ isAutoIncrement: TSchema[K]["isAutoIncrement"];
91
+ } : TSchema[K];
92
+ }> : TAlt extends {
93
+ kind: "dropDefault";
94
+ } ? Prettify<Omit<TSchema, K> & {
95
+ [P in K]: TSchema[K] extends ColumnDescriptor ? {
96
+ tsType: TSchema[K]["tsType"];
97
+ isNullable: TSchema[K]["isNullable"];
98
+ hasDefault: false;
99
+ isAutoIncrement: TSchema[K]["isAutoIncrement"];
100
+ } : TSchema[K];
101
+ }> : TAlt extends {
102
+ kind: "setNotNull";
103
+ } ? Prettify<Omit<TSchema, K> & {
104
+ [P in K]: TSchema[K] extends ColumnDescriptor ? {
105
+ tsType: TSchema[K]["tsType"];
106
+ isNullable: false;
107
+ hasDefault: TSchema[K]["hasDefault"];
108
+ isAutoIncrement: TSchema[K]["isAutoIncrement"];
109
+ } : TSchema[K];
110
+ }> : TAlt extends {
111
+ kind: "dropNotNull";
112
+ } ? Prettify<Omit<TSchema, K> & {
113
+ [P in K]: TSchema[K] extends ColumnDescriptor ? {
114
+ tsType: TSchema[K]["tsType"];
115
+ isNullable: true;
116
+ hasDefault: TSchema[K]["hasDefault"];
117
+ isAutoIncrement: TSchema[K]["isAutoIncrement"];
118
+ } : TSchema[K];
119
+ }> : TSchema : TSchema : THeadOp extends ModifyColumnOp<infer K, any, infer TDescriptor> ? Prettify<Omit<TSchema, K> & {
120
+ [P in K]: TDescriptor;
71
121
  }> : TSchema;
72
122
  /**
73
123
  * Recursively processes a list of alteration operations (AST)
@@ -15,4 +15,70 @@
15
15
  * @param startMarker The marker in the document to start injecting the app.
16
16
  * @param endMarker The marker in the app stream that signals the end of the initial, non-suspended render.
17
17
  */
18
+ /**
19
+ * A utility that orchestrates and interleaves three ReadableStreams to produce a
20
+ * single, valid HTML response stream. It uses two special markers:
21
+ *
22
+ * - `startMarker`: Placed in the `outerHtml` stream (the document shell) to
23
+ * designate where the application's content should be injected.
24
+ * - `endMarker`: Injected into the `innerHtml` stream's RSC payload to signal
25
+ * the end of the initial, non-suspended render. This marker is needed for
26
+ * non-blocking hydration, as it allows the stitching process to send the
27
+ * client `<script>` tags before all suspended content has resolved.
28
+ *
29
+ * It manages three main stream readers:
30
+ *
31
+ * - `hoistedTagsReader`: Reads from the `hoistedTagsStream`, which contains only
32
+ * the hoisted meta tags (e.g., `<title>`, `<meta>`).
33
+ * - `outerReader`: Reads from the `outerHtml` stream, which is the server-rendered
34
+ * document shell (containing `<html>`, `<head>`, etc.).
35
+ * - `innerReader`: Reads from the `appBodyStream`, which contains the main
36
+ * application content, stripped of its hoisted tags.
37
+ *
38
+ * The function proceeds through a multi-phase state machine, managed by the
39
+ * `pump` function, to correctly interleave these streams.
40
+ *
41
+ * The state machine moves through the following phases:
42
+ *
43
+ * 1. `read-hoisted`:
44
+ * - **Goal:** Buffer all hoisted tags from the `hoistedTagsStream`.
45
+ * - **Action:** Reads from `hoistedTagsReader` and appends all content into
46
+ * the `hoistedTagsBuffer`. Does not enqueue anything yet.
47
+ * - **Transition:** Moves to `outer-head` when the stream is exhausted.
48
+ *
49
+ * 2. `outer-head`:
50
+ * - **Goal:** Stream the document up to the closing `</head>` tag, inject the
51
+ * hoisted tags, and then continue until the app `startMarker`.
52
+ * - **Action:** Reads from `outerReader`. When it finds `</head>`, it enqueues
53
+ * the content before it, then enqueues the `hoistedTagsBuffer`, and finally
54
+ * enqueues the `</head>` tag itself. It then continues reading from
55
+ * `outerReader` until it finds the `startMarker`.
56
+ * - **Transition:** Moves to `inner-shell` after finding and discarding the
57
+ * `startMarker`.
58
+ *
59
+ * 3. `inner-shell`:
60
+ * - **Goal:** Stream the initial, non-suspended part of the application.
61
+ * - **Action:** Switches to `innerReader`. It enqueues chunks until it finds
62
+ * the `endMarker`. Any content after the marker is stored in
63
+ * `innerSuspendedRemains`.
64
+ * - **Transition:** Moves to `outer-tail` after finding the `endMarker`.
65
+ *
66
+ * 4. `outer-tail`:
67
+ * - **Goal:** Stream the rest of the document's `<body>`, including client
68
+ * `<script>` tags.
69
+ * - **Action:** Switches back to `outerReader` and enqueues chunks until it
70
+ * finds the `</body>` tag.
71
+ * - **Transition:** Moves to `inner-suspended` after finding `</body>`.
72
+ *
73
+ * 5. `inner-suspended`:
74
+ * - **Goal:** Stream any suspended content from the React app.
75
+ * - **Action:** First enqueues any content from `innerSuspendedRemains`, then
76
+ * continues reading from `innerReader` until the stream is exhausted.
77
+ * - **Transition:** Moves to `outer-end` when the stream is exhausted.
78
+ *
79
+ * 6. `outer-end`:
80
+ * - **Goal:** Finish the document.
81
+ * - **Action:** Switches back to `outerReader` for the last time to send the
82
+ * closing `</body>` and `</html>` tags.
83
+ */
18
84
  export declare function stitchDocumentAndAppStreams(outerHtml: ReadableStream<Uint8Array>, innerHtml: ReadableStream<Uint8Array>, startMarker: string, endMarker: string): ReadableStream<Uint8Array>;
@@ -105,6 +105,72 @@ function splitStreamOnFirstNonHoistedTag(sourceStream) {
105
105
  });
106
106
  return [hoistedTagsStream, appBodyStream];
107
107
  }
108
+ /**
109
+ * A utility that orchestrates and interleaves three ReadableStreams to produce a
110
+ * single, valid HTML response stream. It uses two special markers:
111
+ *
112
+ * - `startMarker`: Placed in the `outerHtml` stream (the document shell) to
113
+ * designate where the application's content should be injected.
114
+ * - `endMarker`: Injected into the `innerHtml` stream's RSC payload to signal
115
+ * the end of the initial, non-suspended render. This marker is needed for
116
+ * non-blocking hydration, as it allows the stitching process to send the
117
+ * client `<script>` tags before all suspended content has resolved.
118
+ *
119
+ * It manages three main stream readers:
120
+ *
121
+ * - `hoistedTagsReader`: Reads from the `hoistedTagsStream`, which contains only
122
+ * the hoisted meta tags (e.g., `<title>`, `<meta>`).
123
+ * - `outerReader`: Reads from the `outerHtml` stream, which is the server-rendered
124
+ * document shell (containing `<html>`, `<head>`, etc.).
125
+ * - `innerReader`: Reads from the `appBodyStream`, which contains the main
126
+ * application content, stripped of its hoisted tags.
127
+ *
128
+ * The function proceeds through a multi-phase state machine, managed by the
129
+ * `pump` function, to correctly interleave these streams.
130
+ *
131
+ * The state machine moves through the following phases:
132
+ *
133
+ * 1. `read-hoisted`:
134
+ * - **Goal:** Buffer all hoisted tags from the `hoistedTagsStream`.
135
+ * - **Action:** Reads from `hoistedTagsReader` and appends all content into
136
+ * the `hoistedTagsBuffer`. Does not enqueue anything yet.
137
+ * - **Transition:** Moves to `outer-head` when the stream is exhausted.
138
+ *
139
+ * 2. `outer-head`:
140
+ * - **Goal:** Stream the document up to the closing `</head>` tag, inject the
141
+ * hoisted tags, and then continue until the app `startMarker`.
142
+ * - **Action:** Reads from `outerReader`. When it finds `</head>`, it enqueues
143
+ * the content before it, then enqueues the `hoistedTagsBuffer`, and finally
144
+ * enqueues the `</head>` tag itself. It then continues reading from
145
+ * `outerReader` until it finds the `startMarker`.
146
+ * - **Transition:** Moves to `inner-shell` after finding and discarding the
147
+ * `startMarker`.
148
+ *
149
+ * 3. `inner-shell`:
150
+ * - **Goal:** Stream the initial, non-suspended part of the application.
151
+ * - **Action:** Switches to `innerReader`. It enqueues chunks until it finds
152
+ * the `endMarker`. Any content after the marker is stored in
153
+ * `innerSuspendedRemains`.
154
+ * - **Transition:** Moves to `outer-tail` after finding the `endMarker`.
155
+ *
156
+ * 4. `outer-tail`:
157
+ * - **Goal:** Stream the rest of the document's `<body>`, including client
158
+ * `<script>` tags.
159
+ * - **Action:** Switches back to `outerReader` and enqueues chunks until it
160
+ * finds the `</body>` tag.
161
+ * - **Transition:** Moves to `inner-suspended` after finding `</body>`.
162
+ *
163
+ * 5. `inner-suspended`:
164
+ * - **Goal:** Stream any suspended content from the React app.
165
+ * - **Action:** First enqueues any content from `innerSuspendedRemains`, then
166
+ * continues reading from `innerReader` until the stream is exhausted.
167
+ * - **Transition:** Moves to `outer-end` when the stream is exhausted.
168
+ *
169
+ * 6. `outer-end`:
170
+ * - **Goal:** Finish the document.
171
+ * - **Action:** Switches back to `outerReader` for the last time to send the
172
+ * closing `</body>` and `</html>` tags.
173
+ */
108
174
  export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, endMarker) {
109
175
  const [hoistedTagsStream, appBodyStream] = splitStreamOnFirstNonHoistedTag(innerHtml);
110
176
  const decoder = new TextDecoder();
@@ -115,138 +181,211 @@ export function stitchDocumentAndAppStreams(outerHtml, innerHtml, startMarker, e
115
181
  let buffer = "";
116
182
  let outerBufferRemains = "";
117
183
  let innerSuspendedRemains = "";
118
- let phase = "enqueue-hoisted";
184
+ let hoistedTagsBuffer = "";
185
+ let hoistedTagsReady = false;
186
+ let phase = "read-hoisted";
119
187
  const pump = async (controller) => {
188
+ const enqueue = (text) => {
189
+ if (text) {
190
+ controller.enqueue(encoder.encode(text));
191
+ }
192
+ };
193
+ const flush = () => {
194
+ const flushIndex = buffer.lastIndexOf("\n");
195
+ if (flushIndex !== -1) {
196
+ enqueue(buffer.slice(0, flushIndex + 1));
197
+ buffer = buffer.slice(flushIndex + 1);
198
+ }
199
+ };
120
200
  try {
121
- if (phase === "enqueue-hoisted") {
201
+ if (phase === "read-hoisted") {
202
+ // Continuously read from the hoisted tags stream and buffer the
203
+ // content. Once the stream is finished, transition to the next phase.
122
204
  const { done, value } = await hoistedTagsReader.read();
205
+ // When the stream is done, we're ready to process the document head.
123
206
  if (done) {
207
+ hoistedTagsReady = true;
124
208
  phase = "outer-head";
125
209
  }
126
210
  else {
127
- controller.enqueue(value);
211
+ // Otherwise, keep appending to the buffer.
212
+ hoistedTagsBuffer += decoder.decode(value, { stream: true });
128
213
  }
129
214
  }
130
215
  else if (phase === "outer-head") {
216
+ // Read from the document stream. Search for the closing `</head>` tag
217
+ // to inject the buffered hoisted tags. Then, search for the
218
+ // `startMarker` to know when to start injecting the app shell. Once
219
+ // the marker is found, transition to the next phase.
131
220
  const { done, value } = await outerReader.read();
221
+ // Handle the case where the document stream ends.
132
222
  if (done) {
223
+ // If there's content left in the buffer, process it for markers.
133
224
  if (buffer) {
225
+ const headCloseIndex = buffer.indexOf("</head>");
226
+ if (headCloseIndex !== -1 &&
227
+ hoistedTagsReady &&
228
+ hoistedTagsBuffer) {
229
+ enqueue(buffer.slice(0, headCloseIndex));
230
+ enqueue(hoistedTagsBuffer);
231
+ hoistedTagsBuffer = "";
232
+ enqueue("</head>");
233
+ buffer = buffer.slice(headCloseIndex + "</head>".length);
234
+ }
134
235
  const markerIndex = buffer.indexOf(startMarker);
135
236
  if (markerIndex !== -1) {
136
- controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
237
+ enqueue(buffer.slice(0, markerIndex));
137
238
  outerBufferRemains = buffer.slice(markerIndex + startMarker.length);
138
239
  }
139
240
  else {
140
- controller.enqueue(encoder.encode(buffer));
241
+ enqueue(buffer);
141
242
  }
142
243
  buffer = "";
143
244
  }
245
+ else if (hoistedTagsReady && hoistedTagsBuffer) {
246
+ enqueue(hoistedTagsBuffer);
247
+ hoistedTagsBuffer = "";
248
+ }
249
+ // Even if the stream ends, we must proceed to the app shell phase.
144
250
  phase = "inner-shell";
145
251
  }
146
252
  else {
253
+ // As chunks arrive, append them to the buffer.
147
254
  buffer += decoder.decode(value, { stream: true });
255
+ // Search for the closing head tag to inject hoisted tags.
256
+ const headCloseIndex = buffer.indexOf("</head>");
257
+ if (headCloseIndex !== -1 && hoistedTagsReady && hoistedTagsBuffer) {
258
+ enqueue(buffer.slice(0, headCloseIndex));
259
+ enqueue(hoistedTagsBuffer);
260
+ hoistedTagsBuffer = "";
261
+ enqueue("</head>");
262
+ buffer = buffer.slice(headCloseIndex + "</head>".length);
263
+ }
264
+ // Search for the start marker to switch to the app stream.
148
265
  const markerIndex = buffer.indexOf(startMarker);
149
266
  if (markerIndex !== -1) {
150
- controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
267
+ enqueue(buffer.slice(0, markerIndex));
151
268
  outerBufferRemains = buffer.slice(markerIndex + startMarker.length);
152
269
  buffer = "";
153
270
  phase = "inner-shell";
154
271
  }
155
272
  else {
156
- const flushIndex = buffer.lastIndexOf("\n");
157
- if (flushIndex !== -1) {
158
- controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
159
- buffer = buffer.slice(flushIndex + 1);
160
- }
273
+ // If no marker is found yet, flush the buffer up to the last
274
+ // newline to keep the stream flowing.
275
+ flush();
161
276
  }
162
277
  }
163
278
  }
164
279
  else if (phase === "inner-shell") {
280
+ // Now read from the app stream. We send the initial part of the app
281
+ // content until we find the `endMarker`. This marker tells us that the
282
+ // non-suspended part of the app is rendered. Any content after this
283
+ // marker is considered suspended and is buffered. Then, transition.
165
284
  const { done, value } = await innerReader.read();
285
+ // Handle the case where the app stream ends.
166
286
  if (done) {
167
287
  if (buffer)
168
- controller.enqueue(encoder.encode(buffer));
288
+ enqueue(buffer);
169
289
  phase = "outer-tail";
170
290
  }
171
291
  else {
292
+ // As chunks arrive, append them to the buffer.
172
293
  buffer += decoder.decode(value, { stream: true });
173
294
  const markerIndex = buffer.indexOf(endMarker);
295
+ // If the end marker is found, enqueue content up to the marker,
296
+ // buffer the rest, and switch to the document tail phase.
174
297
  if (markerIndex !== -1) {
175
298
  const endOfMarkerIndex = markerIndex + endMarker.length;
176
- controller.enqueue(encoder.encode(buffer.slice(0, endOfMarkerIndex)));
299
+ enqueue(buffer.slice(0, endOfMarkerIndex));
177
300
  innerSuspendedRemains = buffer.slice(endOfMarkerIndex);
178
301
  buffer = "";
179
302
  phase = "outer-tail";
180
303
  }
181
304
  else {
182
- const flushIndex = buffer.lastIndexOf("\n");
183
- if (flushIndex !== -1) {
184
- controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
185
- buffer = buffer.slice(flushIndex + 1);
186
- }
305
+ // If no marker is found yet, flush the buffer.
306
+ flush();
187
307
  }
188
308
  }
189
309
  }
190
310
  else if (phase === "outer-tail") {
311
+ // Switch back to the document stream. The goal is to send the rest of
312
+ // the document's body, which critically includes the client-side
313
+ // `<script>` tags for hydration. We stream until we find the closing
314
+ // `</body>` tag and then transition.
315
+ // First, process any leftover buffer from the `outer-head` phase.
191
316
  if (outerBufferRemains) {
192
317
  buffer = outerBufferRemains;
193
318
  outerBufferRemains = "";
194
319
  }
195
320
  const { done, value } = await outerReader.read();
321
+ // Handle the case where the document stream ends.
196
322
  if (done) {
197
323
  if (buffer) {
324
+ // Search the remaining buffer for the closing body tag.
198
325
  const markerIndex = buffer.indexOf("</body>");
199
326
  if (markerIndex !== -1) {
200
- controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
327
+ enqueue(buffer.slice(0, markerIndex));
201
328
  buffer = buffer.slice(markerIndex);
202
329
  }
203
330
  else {
204
- controller.enqueue(encoder.encode(buffer));
331
+ enqueue(buffer);
205
332
  buffer = "";
206
333
  }
207
334
  }
335
+ // Proceed to the suspended content phase.
208
336
  phase = "inner-suspended";
209
337
  }
210
338
  else {
339
+ // As chunks arrive, append them to the buffer.
211
340
  buffer += decoder.decode(value, { stream: true });
341
+ // Search for the closing body tag to switch to suspended content.
212
342
  const markerIndex = buffer.indexOf("</body>");
213
343
  if (markerIndex !== -1) {
214
- controller.enqueue(encoder.encode(buffer.slice(0, markerIndex)));
344
+ enqueue(buffer.slice(0, markerIndex));
215
345
  buffer = buffer.slice(markerIndex);
216
346
  phase = "inner-suspended";
217
347
  }
218
348
  else {
219
- const flushIndex = buffer.lastIndexOf("\n");
220
- if (flushIndex !== -1) {
221
- controller.enqueue(encoder.encode(buffer.slice(0, flushIndex + 1)));
222
- buffer = buffer.slice(flushIndex + 1);
223
- }
349
+ // If no marker is found yet, flush the buffer.
350
+ flush();
224
351
  }
225
352
  }
226
353
  }
227
354
  else if (phase === "inner-suspended") {
355
+ // Switch back to the app stream. First, send any buffered suspended
356
+ // content from the `inner-shell` phase. Then, stream the rest of the
357
+ // app content until it's finished. This is all the content that was
358
+ // behind a `<Suspense>` boundary.
359
+ // First, send any buffered suspended content from the `inner-shell` phase.
228
360
  if (innerSuspendedRemains) {
229
- controller.enqueue(encoder.encode(innerSuspendedRemains));
361
+ enqueue(innerSuspendedRemains);
230
362
  innerSuspendedRemains = "";
231
363
  }
232
364
  const { done, value } = await innerReader.read();
365
+ // When the app stream is done, transition to the final phase.
233
366
  if (done) {
234
367
  phase = "outer-end";
235
368
  }
236
369
  else {
370
+ // Otherwise, pass through the remaining app content directly.
237
371
  controller.enqueue(value);
238
372
  }
239
373
  }
240
374
  else if (phase === "outer-end") {
375
+ // Finally, switch back to the document stream one last time to send
376
+ // the closing `</body>` and `</html>` tags and finish the response.
377
+ // First, send any leftover buffer from the `outer-tail` phase.
241
378
  if (buffer) {
242
- controller.enqueue(encoder.encode(buffer));
379
+ enqueue(buffer);
243
380
  buffer = "";
244
381
  }
245
382
  const { done, value } = await outerReader.read();
383
+ // When the document stream is done, we're finished.
246
384
  if (done) {
247
385
  controller.close();
248
386
  return;
249
387
  }
388
+ // Otherwise, pass through the final document content.
250
389
  controller.enqueue(value);
251
390
  }
252
391
  await pump(controller);
@@ -45,7 +45,7 @@ describe("stitchDocumentAndAppStreams", () => {
45
45
  const startMarker = '<div id="rwsdk-app-start" />';
46
46
  const endMarker = '<div id="rwsdk-app-end"></div>';
47
47
  describe("meta tag hoisting", () => {
48
- it("extracts and prepends single title tag", async () => {
48
+ it("places hoisted tags inside head, after existing head content", async () => {
49
49
  const outerHtml = `<!DOCTYPE html>
50
50
  <html>
51
51
  <head>
@@ -59,10 +59,19 @@ describe("stitchDocumentAndAppStreams", () => {
59
59
  const innerHtml = `<title>Page Title</title><div>App content</div>${endMarker}`;
60
60
  const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
61
61
  expect(result).toContain(`<title>Page Title</title>`);
62
- expect(result.indexOf(`<title>Page Title</title>`)).toBeLessThan(result.indexOf(`<!DOCTYPE html>`));
62
+ expect(result).toMatch(/<head>[\s\S]*<meta charset="utf-8" \/>[\s\S]*<title>Page Title<\/title>[\s\S]*<\/head>/);
63
63
  expect(result).toContain(`<div>App content</div>`);
64
+ const doctypeIndex = result.indexOf(`<!DOCTYPE html>`);
65
+ const headIndex = result.indexOf(`<head>`);
66
+ const charsetIndex = result.indexOf(`<meta charset="utf-8" />`);
67
+ const titleIndex = result.indexOf(`<title>Page Title</title>`);
68
+ const headCloseIndex = result.indexOf(`</head>`);
69
+ expect(doctypeIndex).toBe(0);
70
+ expect(doctypeIndex).toBeLessThan(headIndex);
71
+ expect(charsetIndex).toBeLessThan(titleIndex);
72
+ expect(titleIndex).toBeLessThan(headCloseIndex);
64
73
  });
65
- it("extracts and prepends multiple hoisted tags", async () => {
74
+ it("places multiple hoisted tags inside head, after existing head content", async () => {
66
75
  const outerHtml = `<!DOCTYPE html>
67
76
  <html>
68
77
  <head>
@@ -78,9 +87,14 @@ describe("stitchDocumentAndAppStreams", () => {
78
87
  expect(result).toContain(`<title>Page Title</title>`);
79
88
  expect(result).toContain(`<meta name="description" content="Test" />`);
80
89
  expect(result).toContain(`<link rel="stylesheet" href="/styles.css" />`);
81
- const hoistedStart = result.indexOf(`<title>Page Title</title>`);
82
- const doctypeStart = result.indexOf(`<!DOCTYPE html>`);
83
- expect(hoistedStart).toBeLessThan(doctypeStart);
90
+ expect(result).toMatch(/<head>[\s\S]*<meta charset="utf-8" \/>[\s\S]*<title>Page Title<\/title>[\s\S]*<meta name="description" content="Test" \/>[\s\S]*<link rel="stylesheet" href="\/styles.css" \/>[\s\S]*<\/head>/);
91
+ const doctypeIndex = result.indexOf(`<!DOCTYPE html>`);
92
+ const charsetIndex = result.indexOf(`<meta charset="utf-8" />`);
93
+ const titleIndex = result.indexOf(`<title>Page Title</title>`);
94
+ const headCloseIndex = result.indexOf(`</head>`);
95
+ expect(doctypeIndex).toBe(0);
96
+ expect(charsetIndex).toBeLessThan(titleIndex);
97
+ expect(titleIndex).toBeLessThan(headCloseIndex);
84
98
  });
85
99
  it("handles app stream with no hoisted tags", async () => {
86
100
  const outerHtml = `<!DOCTYPE html>
@@ -117,9 +131,29 @@ describe("stitchDocumentAndAppStreams", () => {
117
131
  const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), createChunkedStream(innerHtmlChunks), startMarker, endMarker));
118
132
  expect(result).toContain(`<title>Page Title</title>`);
119
133
  expect(result).toContain(`<meta name="description" content="Test" />`);
120
- const hoistedStart = result.indexOf(`<title>Page Title</title>`);
121
- const doctypeStart = result.indexOf(`<!DOCTYPE html>`);
122
- expect(hoistedStart).toBeLessThan(doctypeStart);
134
+ expect(result).toMatch(/<head>[\s\S]*<meta charset="utf-8" \/>[\s\S]*<title>Page Title<\/title>[\s\S]*<\/head>/);
135
+ const doctypeIndex = result.indexOf(`<!DOCTYPE html>`);
136
+ const charsetIndex = result.indexOf(`<meta charset="utf-8" />`);
137
+ const titleIndex = result.indexOf(`<title>Page Title</title>`);
138
+ const headCloseIndex = result.indexOf(`</head>`);
139
+ expect(doctypeIndex).toBe(0);
140
+ expect(charsetIndex).toBeLessThan(titleIndex);
141
+ expect(titleIndex).toBeLessThan(headCloseIndex);
142
+ });
143
+ it("ensures doctype is always first", async () => {
144
+ const outerHtml = `<!DOCTYPE html>
145
+ <html>
146
+ <head>
147
+ <meta charset="utf-8" />
148
+ </head>
149
+ <body>
150
+ ${startMarker}
151
+ <script src="/client.js"></script>
152
+ </body>
153
+ </html>`;
154
+ const innerHtml = `<title>Page Title</title><div>App content</div>${endMarker}`;
155
+ const result = await streamToString(stitchDocumentAndAppStreams(stringToStream(outerHtml), stringToStream(innerHtml), startMarker, endMarker));
156
+ expect(result.trim().startsWith(`<!DOCTYPE html>`)).toBe(true);
123
157
  });
124
158
  });
125
159
  describe("basic stitching flow", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.0.0-beta.27-test.20251116215153",
3
+ "version": "1.0.0-beta.28",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {