js-bao 0.7.0-alpha.6 → 0.7.0-alpha.7

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.
package/dist/browser.cjs CHANGED
@@ -5225,6 +5225,19 @@ var init_OrmContext = __esm({
5225
5225
  format2Documents = /* @__PURE__ */ new Map();
5226
5226
  /** Format-2 epoch moves in flight (#2816). */
5227
5227
  format2Transitions = /* @__PURE__ */ new Map();
5228
+ /**
5229
+ * This instance's document observers' database writes that have not finished
5230
+ * yet, by document id (#3023).
5231
+ *
5232
+ * A Yjs observer fires synchronously with the update but writes to the
5233
+ * database asynchronously, so an update that has been APPLIED to a document
5234
+ * is not yet QUERYABLE. Whoever needs the difference — an `open()` that
5235
+ * promises its caller the synced records — waits on
5236
+ * {@link whenDocumentIndexed}. Per instance, not process-wide: two clients
5237
+ * holding the same document id write to their own engines, and neither
5238
+ * should be held up by the other's writes.
5239
+ */
5240
+ documentIndexingWork = /* @__PURE__ */ new Map();
5228
5241
  /**
5229
5242
  * Who this ORM instance is in a format-2 store's unacknowledged-write log
5230
5243
  * (#3431).
@@ -5402,6 +5415,56 @@ var init_OrmContext = __esm({
5402
5415
  release();
5403
5416
  }
5404
5417
  }
5418
+ /**
5419
+ * Record one observer's asynchronous write of `docId` (#3023), so a caller
5420
+ * that needs the document queryable can wait for it. Failures are the
5421
+ * observer's own to log; a tracked promise never rejects, because the point
5422
+ * of the barrier is only to know when the write is over.
5423
+ *
5424
+ * A destroyed context tracks nothing: its observers are already inert, and a
5425
+ * torn-down instance must not leave a barrier that never drains.
5426
+ */
5427
+ trackDocumentIndexing(docId, work) {
5428
+ if (this.destroyed) return;
5429
+ let inFlight = this.documentIndexingWork.get(docId);
5430
+ if (!inFlight) {
5431
+ inFlight = /* @__PURE__ */ new Set();
5432
+ this.documentIndexingWork.set(docId, inFlight);
5433
+ }
5434
+ const pending = work.catch(() => {
5435
+ }).then(() => {
5436
+ inFlight.delete(pending);
5437
+ if (inFlight.size === 0 && this.documentIndexingWork.get(docId) === inFlight) {
5438
+ this.documentIndexingWork.delete(docId);
5439
+ }
5440
+ });
5441
+ inFlight.add(pending);
5442
+ }
5443
+ /**
5444
+ * Resolve once `docId`'s applied changes have reached this instance's query
5445
+ * tables (#3023). Answers `true` when the work drained, `false` when it gave
5446
+ * up waiting — the caller owns the logging, because this module holds no
5447
+ * runtime imports.
5448
+ *
5449
+ * An update applied to the Y.Doc is visible to a reader of the document
5450
+ * immediately, but the query tables only catch up when the observers' writes
5451
+ * land. An `open()` that waited for the initial sync waits for this too —
5452
+ * otherwise it resolves on the sync signal and the caller's first `query()`
5453
+ * sees a partly-written table.
5454
+ *
5455
+ * A write can enqueue more work (a nested map observed as it arrives), so
5456
+ * this drains in passes rather than once, and gives up after enough of them
5457
+ * that only a document under continuous change could still be busy — a
5458
+ * barrier is not a reason to hang.
5459
+ */
5460
+ async whenDocumentIndexed(docId) {
5461
+ for (let pass = 0; pass < 50; pass++) {
5462
+ const inFlight = this.documentIndexingWork.get(docId);
5463
+ if (!inFlight || inFlight.size === 0) return true;
5464
+ await Promise.all([...inFlight]);
5465
+ }
5466
+ return false;
5467
+ }
5405
5468
  /** Notify `subscribe()` callbacks registered on this context for `modelName`. */
5406
5469
  notifyListeners(modelName, log, onError) {
5407
5470
  const listeners = this.listenersMap.get(modelName);
@@ -5527,6 +5590,7 @@ var init_OrmContext = __esm({
5527
5590
  this.modelDocMappingChangedListeners.clear();
5528
5591
  this.format2Documents.clear();
5529
5592
  this.format2Transitions.clear();
5593
+ this.documentIndexingWork.clear();
5530
5594
  this.observedStringSetMaps = /* @__PURE__ */ new WeakSet();
5531
5595
  liveOrmContexts.delete(this);
5532
5596
  if (defaultOrmContext === this) {
@@ -5897,6 +5961,10 @@ var init_ModelRegistry = __esm({
5897
5961
  * undefined the legacy session semantics are kept exactly: the global
5898
5962
  * registry is SNAPSHOT at init time, so a model registered afterwards is not
5899
5963
  * silently adopted by an already-running instance.
5964
+ *
5965
+ * The one way a running instance's registry grows is {@link adoptModel},
5966
+ * behind `registerModel` on the init result (#3615): an EXPLICIT call on the
5967
+ * instance the app means, not a relaxation of the snapshot rule.
5900
5968
  */
5901
5969
  static createInstanceRegistry(modelClasses) {
5902
5970
  const registry = new _ModelRegistry();
@@ -5920,6 +5988,64 @@ var init_ModelRegistry = __esm({
5920
5988
  registry.setExplicitModelsForSession(modelClasses);
5921
5989
  return registry;
5922
5990
  }
5991
+ /**
5992
+ * The name `modelClass` may be adopted under on THIS instance, or a refusal
5993
+ * that names what to change (#3615).
5994
+ *
5995
+ * Both checks are made before anything is mutated, so a registration that
5996
+ * cannot go through leaves the registry exactly as it was.
5997
+ */
5998
+ assertAdoptable(modelClass, instanceId) {
5999
+ const schema = modelClass?.getSchema?.();
6000
+ const modelName = modelClass?.modelName ?? schema?.options?.name;
6001
+ if (!modelName || !schema?.options?.name || !schema?.resolvedUniqueConstraints) {
6002
+ throw new Error(
6003
+ `[initJsBao] Cannot register a model class with no name. Build it with defineModelSchema and attachAndRegisterModel (or createModelClass) so it carries a static modelName and a schema with resolvedUniqueConstraints.`
6004
+ );
6005
+ }
6006
+ const existing = this.models.get(modelName);
6007
+ if (existing && existing !== modelClass) {
6008
+ throw new Error(
6009
+ `[initJsBao] Model '${modelName}' is already registered on js-bao instance #${instanceId} with a different class. Register the existing class, or give the new model a different name.`
6010
+ );
6011
+ }
6012
+ return modelName;
6013
+ }
6014
+ /**
6015
+ * Adopt `modelClass` into THIS instance's registry at runtime (#3615).
6016
+ *
6017
+ * The counterpart to the snapshot rule on
6018
+ * {@link ModelRegistry.createInstanceRegistry}: an instance grows only where
6019
+ * the app says so, and the global singleton is deliberately not touched —
6020
+ * registering here makes the model visible to this instance and to no other.
6021
+ *
6022
+ * Returns `{ added: false }` when the very same class is already ACTIVE on
6023
+ * this instance, which is what makes a repeat `registerModel` a no-op rather
6024
+ * than a warning about overwriting. Active, not merely present: an instance
6025
+ * built with `models: []` carries the whole global registry in `models` with
6026
+ * nothing active, and a class only the backing snapshot holds has neither
6027
+ * been validated for this session nor bound to its context, so it still has
6028
+ * an adoption to go through.
6029
+ */
6030
+ adoptModel(modelClass, options = {}) {
6031
+ const modelName = this.assertAdoptable(modelClass, options.instanceId);
6032
+ if (this.getActiveModels().get(modelName) === modelClass) {
6033
+ return { added: false };
6034
+ }
6035
+ const candidate = new Map(this.getActiveModels());
6036
+ candidate.set(modelName, modelClass);
6037
+ this.validateModels(candidate);
6038
+ const schema = modelClass.getSchema?.();
6039
+ this.models.set(modelName, modelClass);
6040
+ if (schema?.options) this.modelOptions.set(modelName, schema.options);
6041
+ if (this.activeSessionModels) {
6042
+ this.activeSessionModels.set(modelName, modelClass);
6043
+ }
6044
+ Logger.debug(
6045
+ `[ModelRegistry] Model "${modelName}" adopted by instance #${options.instanceId} at runtime.`
6046
+ );
6047
+ return { added: true };
6048
+ }
5923
6049
  registerModel(modelClass, options, fields) {
5924
6050
  if (!options.name) {
5925
6051
  throw new Error(
@@ -6122,7 +6248,8 @@ var init_ModelRegistry = __esm({
6122
6248
  Logger.debug(`[ModelRegistry] Data removal complete for document ${docId}`);
6123
6249
  }
6124
6250
  // New method to initialize relationships
6125
- async initializeRelationships() {
6251
+ async initializeRelationships(fallbackDbEngine) {
6252
+ if (!this.dbEngine && fallbackDbEngine) this.dbEngine = fallbackDbEngine;
6126
6253
  if (!this.dbEngine) {
6127
6254
  console.error(
6128
6255
  "[ModelRegistry] DB engine not available for initializing relationships. Ensure initializeAll has been called."
@@ -6274,7 +6401,16 @@ var init_ModelRegistry = __esm({
6274
6401
  return this.activeSessionModels || this.models;
6275
6402
  }
6276
6403
  validateSessionModels() {
6277
- const activeModels = this.getActiveModels();
6404
+ this.validateModels(this.getActiveModels());
6405
+ }
6406
+ /**
6407
+ * Every relationship of `activeModels` names a model that is also in it.
6408
+ *
6409
+ * Taken as an argument rather than read off the registry so a runtime
6410
+ * adoption can be validated against what the instance WOULD hold before it
6411
+ * holds it (#3615).
6412
+ */
6413
+ validateModels(activeModels) {
6278
6414
  if (!activeModels || activeModels.size === 0) {
6279
6415
  return;
6280
6416
  }
@@ -6726,6 +6862,14 @@ var init_IncludeResolver = __esm({
6726
6862
  function generateULID() {
6727
6863
  return (0, import_ulid2.ulid)();
6728
6864
  }
6865
+ function reindexFailure(modelConstructor, modelName, recordId, docId, error) {
6866
+ const message = error instanceof Error ? error.message : String(error);
6867
+ const failure = new Error(
6868
+ `[${modelConstructor.name}] Error re-indexing ${modelName}/${recordId} of document ${docId}: ${message}`
6869
+ );
6870
+ failure.cause = error;
6871
+ return failure;
6872
+ }
6729
6873
  var Y7, import_ulid2, FORMAT2_ROW_PAGE, UniqueConstraintViolationError, RecordNotFoundError, SCHEMA_ACCESSORS_KEY, BaseModel2;
6730
6874
  var init_BaseModel = __esm({
6731
6875
  "src/models/BaseModel.ts"() {
@@ -7598,19 +7742,31 @@ var init_BaseModel = __esm({
7598
7742
  if (fieldOptions?.default === void 0) return void 0;
7599
7743
  return typeof fieldOptions.default === "function" ? fieldOptions.default() : fieldOptions.default;
7600
7744
  }
7601
- getStringSetFromYjs(fieldName) {
7602
- const yjsData = this.getFromYjs(fieldName);
7603
- if (yjsData instanceof Y7.Map) {
7604
- return Array.from(yjsData.keys());
7745
+ /**
7746
+ * The members of a stringset as it is STORED, in stored order.
7747
+ *
7748
+ * Static and value-only so that the two readers of a stored stringset — the
7749
+ * instance's `getStringSetFromYjs`, which feeds the value a save builds its
7750
+ * unique key from, and `_readableUniqueOwner`, which rebuilds another
7751
+ * record's key from the map — cannot drift apart (#3252).
7752
+ */
7753
+ static _stringSetMembersFromStored(stored) {
7754
+ if (stored instanceof Y7.Map) {
7755
+ return Array.from(stored.keys());
7605
7756
  }
7606
- if (Array.isArray(yjsData)) {
7607
- return yjsData.filter((member) => typeof member === "string");
7757
+ if (Array.isArray(stored)) {
7758
+ return stored.filter((member) => typeof member === "string");
7608
7759
  }
7609
- if (yjsData && typeof yjsData === "object") {
7610
- return Object.keys(yjsData).filter((k) => Boolean(yjsData[k]));
7760
+ if (stored && typeof stored === "object") {
7761
+ return Object.keys(stored).filter((k) => Boolean(stored[k]));
7611
7762
  }
7612
7763
  return [];
7613
7764
  }
7765
+ getStringSetFromYjs(fieldName) {
7766
+ return this.constructor._stringSetMembersFromStored(
7767
+ this.getFromYjs(fieldName)
7768
+ );
7769
+ }
7614
7770
  getOrCreateStringSet(fieldName) {
7615
7771
  if (!this._stringSetFields.has(fieldName)) {
7616
7772
  const initialValues = this.getStringSetFromYjs(fieldName);
@@ -7639,6 +7795,30 @@ var init_BaseModel = __esm({
7639
7795
  `[${this.name}] initialize(yDoc, db) is no longer supported. Use initializeForDocument(yDoc, db, docId, permission)`
7640
7796
  );
7641
7797
  }
7798
+ /**
7799
+ * Record one observer's asynchronous write of `docId` on this class's ORM
7800
+ * instance (#3023), so a caller that needs the document queryable can wait
7801
+ * for it. The observers themselves track through the context they captured
7802
+ * at install time; this static is for callers that only hold a model class.
7803
+ */
7804
+ static trackDocumentIndexing(docId, work) {
7805
+ resolveOrmContext(this).trackDocumentIndexing(docId, work);
7806
+ }
7807
+ /**
7808
+ * Resolve once `docId`'s applied changes have reached this class's instance
7809
+ * of the query tables (#3023). See
7810
+ * {@link OrmContext.whenDocumentIndexed} — an `open()` that waited for the
7811
+ * initial sync waits for this too, otherwise it resolves on the sync signal
7812
+ * and the caller's first `query()` sees a partly-written table.
7813
+ */
7814
+ static async whenDocumentIndexed(docId) {
7815
+ const drained = await resolveOrmContext(this).whenDocumentIndexed(docId);
7816
+ if (!drained) {
7817
+ Logger.warn(
7818
+ `[BaseModel] Document ${docId} is still indexing after 50 drain passes; not waiting any longer`
7819
+ );
7820
+ }
7821
+ }
7642
7822
  /**
7643
7823
  * Initialize this model for a connected document.
7644
7824
  *
@@ -7985,7 +8165,7 @@ var init_BaseModel = __esm({
7985
8165
  return recordIdsToDiscard;
7986
8166
  };
7987
8167
  const observerContext = resolveOrmContext(this);
7988
- const documentYMapObserver = async (event) => {
8168
+ const indexDocumentYMapChange = async (event) => {
7989
8169
  if (observerContext.destroyed) return;
7990
8170
  Logger.verbose(
7991
8171
  `[${this.name}] Document YMap change detected for ${modelName}/${docId}:`,
@@ -8154,6 +8334,12 @@ var init_BaseModel = __esm({
8154
8334
  );
8155
8335
  observerContext.notifyListeners(modelName);
8156
8336
  };
8337
+ const documentYMapObserver = (event) => {
8338
+ observerContext.trackDocumentIndexing(
8339
+ docId,
8340
+ indexDocumentYMapChange(event)
8341
+ );
8342
+ };
8157
8343
  documentYMap.observe(documentYMapObserver);
8158
8344
  observerContext.registerObserverDisposer(
8159
8345
  docId,
@@ -8179,6 +8365,116 @@ var init_BaseModel = __esm({
8179
8365
  `[${this.name}] Model ${modelName} initialization complete for document ${docId}.`
8180
8366
  );
8181
8367
  }
8368
+ /**
8369
+ * Re-index ONE record of a document from its current Y.Map state (#3615).
8370
+ *
8371
+ * A legacy document's initial load walks the Y.Map before the permanent
8372
+ * observers are installed, so an update that lands in that window reaches
8373
+ * Yjs and misses the query tables. For a document connected long ago — which
8374
+ * is exactly what a model registered at runtime is initialized against —
8375
+ * that window is open on a live document. `registerModel` collects the
8376
+ * record ids touched while it runs and brings each of them here afterwards,
8377
+ * writing the same row the permanent per-record observer would have written.
8378
+ *
8379
+ * A record the window DELETED is deleted here for the same reason.
8380
+ *
8381
+ * A storage failure REJECTS rather than being logged and passed over, the
8382
+ * way the permanent observers' writes are: this write is part of what a
8383
+ * registration promises before it resolves, so swallowing it would resolve a
8384
+ * registration whose query tables are missing the very updates this call
8385
+ * exists to catch, and mark the pair complete so no retry would repair it.
8386
+ */
8387
+ static async reindexDocumentRecord(docId, recordId) {
8388
+ const modelConstructor = this;
8389
+ const schema = modelConstructor.getSchema?.();
8390
+ const modelName = schema?.options?.name;
8391
+ if (!modelName) return;
8392
+ const ctx = resolveOrmContext(modelConstructor);
8393
+ if (ctx.destroyed) return;
8394
+ const documentYMap = modelConstructor.documentYMaps.get(
8395
+ `${docId}_${modelName}`
8396
+ );
8397
+ if (!documentYMap) return;
8398
+ const engine = ctx.dbEngine ? await modelConstructor.queryEngineFor([docId], modelName) : null;
8399
+ if (!engine) return;
8400
+ const recordData = documentYMap.get(recordId);
8401
+ if (recordData === void 0 || recordData === null) {
8402
+ try {
8403
+ await engine.delete(modelName, recordId);
8404
+ } catch (error) {
8405
+ throw reindexFailure(
8406
+ modelConstructor,
8407
+ modelName,
8408
+ recordId,
8409
+ docId,
8410
+ error
8411
+ );
8412
+ }
8413
+ ctx.notifyListeners(modelName);
8414
+ return;
8415
+ }
8416
+ const entries = recordData instanceof Y7.Map ? Array.from(recordData.entries()) : Object.entries(recordData);
8417
+ const rowData = {};
8418
+ for (const [fieldKey, value] of entries) {
8419
+ const fieldOptions = schema.fields?.get(fieldKey);
8420
+ if (!fieldOptions || fieldOptions.type === "stringset") continue;
8421
+ if (value === void 0) continue;
8422
+ rowData[fieldKey] = isYjsComposite(value) ? normalizeYjsValueForStorage(value) : value;
8423
+ }
8424
+ if (!rowData.id) return;
8425
+ const permissionHint = modelConstructor.connectedDocuments.get(docId)?.permissionHint ?? "read-write";
8426
+ try {
8427
+ await engine.insert(modelName, {
8428
+ ...rowData,
8429
+ type: modelName,
8430
+ _meta_doc_id: docId,
8431
+ _meta_permission_hint: permissionHint
8432
+ });
8433
+ } catch (error) {
8434
+ throw reindexFailure(
8435
+ modelConstructor,
8436
+ modelName,
8437
+ recordId,
8438
+ docId,
8439
+ error
8440
+ );
8441
+ }
8442
+ ctx.notifyListeners(modelName);
8443
+ }
8444
+ /**
8445
+ * Withdraw ONE document's registration of this model (#3615).
8446
+ *
8447
+ * A `registerModel` that fails part-way through a document keeps the class
8448
+ * registered, because retrying is the recovery. This is what keeps that
8449
+ * half-finished pair from LOOKING finished in the meantime: without the
8450
+ * Y.Map mapping a save raises the same `YMap not found` an unregistered
8451
+ * model raises, instead of writing into a document whose observers were
8452
+ * never installed, and clearing the rows the failed load managed to write
8453
+ * keeps a query from answering out of a document nothing is watching. The
8454
+ * retry's `initializeForDocument` publishes both again.
8455
+ *
8456
+ * `connectedDocuments` is deliberately left alone: it is the CONTEXT's map,
8457
+ * shared by every model on the instance, so the document itself is still
8458
+ * connected for everything else.
8459
+ */
8460
+ static async withdrawDocumentRegistration(docId) {
8461
+ const modelConstructor = this;
8462
+ const schema = modelConstructor.getSchema?.();
8463
+ const modelName = schema?.options?.name;
8464
+ if (!modelName) return;
8465
+ modelConstructor.documentYMaps.delete(`${docId}_${modelName}`);
8466
+ const ctx = resolveOrmContext(modelConstructor);
8467
+ if (ctx.destroyed || !ctx.dbEngine) return;
8468
+ try {
8469
+ const engine = await modelConstructor.queryEngineFor([docId], modelName);
8470
+ await engine.deleteByDocumentId(modelName, docId);
8471
+ } catch (error) {
8472
+ Logger.warn(
8473
+ `[${modelConstructor.name}] Could not clear ${modelName} rows of document ${docId} while withdrawing an incomplete registration:`,
8474
+ error
8475
+ );
8476
+ }
8477
+ }
8182
8478
  // New method to clean up data for a disconnected document
8183
8479
  static async cleanupDocumentData(docId) {
8184
8480
  const modelConstructor = this;
@@ -8437,6 +8733,48 @@ var init_BaseModel = __esm({
8437
8733
  constraintName
8438
8734
  );
8439
8735
  }
8736
+ /**
8737
+ * The record that READABLY owns `uniqueKey` under `constraint` in one
8738
+ * document's model map, or `null`.
8739
+ *
8740
+ * The `_uniqueIdx_` map is a cache over the records, not the record store,
8741
+ * and the two can disagree (#3252): a replica can hold an index entry for a
8742
+ * record whose structs have not landed yet, or an entry a field change or
8743
+ * delete left behind. `save()` used to take the entry at its word, so the
8744
+ * app saw a `query` that returned no row for a value and a save refused for
8745
+ * that same value — the index knew a row nothing else could read.
8746
+ *
8747
+ * An entry counts only when its owner is readable: the record map exists
8748
+ * under that id in `modelYMap`, and the key built from the record's stored
8749
+ * fields is `uniqueKey`. Anything else is a stale entry, and the caller
8750
+ * treats it as an index miss — the engine fallback decides, exactly as it
8751
+ * does for a missing entry — so the write that follows lands and rewrites
8752
+ * the entry to the record that really holds the value.
8753
+ *
8754
+ * "The key built from the record's stored fields" has to mean the key that
8755
+ * record's own save built, so the stored value is read the way the save
8756
+ * read it: a stringset is stored as a nested Y.Map of its members but keyed
8757
+ * as the array `getCurrentJSState` puts in `dataToSave` (finding 3252-01).
8758
+ * Comparing the raw Y.Map would call every readable owner of a unique
8759
+ * stringset stale and let a duplicate straight through the constraint.
8760
+ */
8761
+ static _readableUniqueOwner(modelYMap, constraintMap, constraint, uniqueKey, modelName) {
8762
+ const ownerId = constraintMap.get(uniqueKey);
8763
+ if (!ownerId) return null;
8764
+ const recordYMap = modelYMap?.get(ownerId);
8765
+ if (!(recordYMap instanceof Y7.Map)) return null;
8766
+ const fields = this.getSchema?.()?.fields;
8767
+ const heldKey = this._buildKeyFromValues(
8768
+ constraint.fields,
8769
+ constraint.fields.map((field) => {
8770
+ const stored = recordYMap.get(field);
8771
+ return fields?.get(field)?.type === "stringset" ? this._stringSetMembersFromStored(stored) : stored;
8772
+ }),
8773
+ modelName,
8774
+ constraint.name
8775
+ );
8776
+ return heldKey === uniqueKey ? ownerId : null;
8777
+ }
8440
8778
  /**
8441
8779
  * The connected format-2 document that holds this instance's record, for an
8442
8780
  * instance the constructor could not resolve (#3429, finding 3429-03).
@@ -8634,7 +8972,14 @@ var init_BaseModel = __esm({
8634
8972
  const constraintMap = yDoc.getMap(
8635
8973
  uniqueIndexMapName(modelName, constraint.name)
8636
8974
  );
8637
- if (!constraintMap.get(newUniqueKey)) {
8975
+ const readableOwner = modelConstructor._readableUniqueOwner(
8976
+ targetYMap,
8977
+ constraintMap,
8978
+ constraint,
8979
+ newUniqueKey,
8980
+ modelName
8981
+ );
8982
+ if (!readableOwner) {
8638
8983
  const constraintKeyValues = constraint.fields.map(
8639
8984
  (field) => dataToSave[field]
8640
8985
  );
@@ -8652,7 +8997,13 @@ var init_BaseModel = __esm({
8652
8997
  if (upsertOnConstraint && upsertOnKey) {
8653
8998
  const constraintMapName = `_uniqueIdx_${modelName}_${upsertOnConstraint.name}`;
8654
8999
  const constraintMap = yDoc.getMap(constraintMapName);
8655
- const existingId = constraintMap.get(upsertOnKey);
9000
+ const existingId = modelConstructor._readableUniqueOwner(
9001
+ targetYMap ?? void 0,
9002
+ constraintMap,
9003
+ upsertOnConstraint,
9004
+ upsertOnKey,
9005
+ modelName
9006
+ );
8656
9007
  if (existingId) {
8657
9008
  if (this._constructorProvidedId && this.id !== existingId) {
8658
9009
  throw new Error(
@@ -8752,7 +9103,13 @@ var init_BaseModel = __esm({
8752
9103
  50
8753
9104
  )}'`
8754
9105
  );
8755
- const existingRecordIdWithNewKey = constraintMap.get(newUniqueKey);
9106
+ const existingRecordIdWithNewKey = modelConstructor._readableUniqueOwner(
9107
+ targetYMap ?? void 0,
9108
+ constraintMap,
9109
+ constraint,
9110
+ newUniqueKey,
9111
+ modelName
9112
+ );
8756
9113
  Logger.verbose(
8757
9114
  `[${modelName}] Save Check (Item '${this.id}'): Found Existing ID: '${existingRecordIdWithNewKey}' for Key '${newUniqueKey.substring(
8758
9115
  0,
@@ -10890,7 +11247,7 @@ var init_BaseModel = __esm({
10890
11247
  );
10891
11248
  modelConstructor.attachStringSetObserversToRecord(recordYMap, schema, docId);
10892
11249
  const observerContext = resolveOrmContext(modelConstructor);
10893
- const recordObserver = async (event) => {
11250
+ const indexNestedYMapChange = async (event) => {
10894
11251
  if (observerContext.destroyed) return;
10895
11252
  Logger.verbose(
10896
11253
  `[${modelName}] Nested YMap change detected for record ${recordId} in document ${docId}:`,
@@ -10966,6 +11323,12 @@ var init_BaseModel = __esm({
10966
11323
  }
10967
11324
  observerContext.notifyListeners(modelName);
10968
11325
  };
11326
+ const recordObserver = (event) => {
11327
+ observerContext.trackDocumentIndexing(
11328
+ docId,
11329
+ indexNestedYMapChange(event)
11330
+ );
11331
+ };
10969
11332
  recordYMap.observe(recordObserver);
10970
11333
  observerContext.registerObserverDisposer(
10971
11334
  docId,
@@ -11041,6 +11404,7 @@ __export(browser_exports, {
11041
11404
  encodeOverlayMutation: () => encodeOverlayMutation,
11042
11405
  estimateMaterializedBytes: () => estimateMaterializedBytes,
11043
11406
  features: () => features,
11407
+ findReservedFieldNameInToml: () => findReservedFieldNameInToml,
11044
11408
  foldFormat2Overlay: () => foldFormat2Overlay,
11045
11409
  followEpoch: () => followEpoch,
11046
11410
  format2ClientDDL: () => format2ClientDDL,
@@ -14664,6 +15028,31 @@ var Format2DocumentBinding = class _Format2DocumentBinding {
14664
15028
  * hydration scope; an uncapped one answers with the models this instance
14665
15029
  * registered, which is every model whose rows could be here.
14666
15030
  */
15031
+ /**
15032
+ * Hydrate ONE model's derived query tables for a bound large document
15033
+ * (#3615).
15034
+ *
15035
+ * What a model registered on a running instance needs: the merged view
15036
+ * already holds its rows — the fold and the overlay observer are keyed by
15037
+ * the model name the overlay carries, not by the registry — but the derived
15038
+ * tables `query()` reads were never built, because the model was not in the
15039
+ * registry when the document was connected.
15040
+ *
15041
+ * Forced, because the point is a rebuild: whatever mark a previous session
15042
+ * left says nothing about tables this instance has never projected into.
15043
+ */
15044
+ async hydrateModel(docId, modelName) {
15045
+ const store = this.opened.get(docId);
15046
+ if (!store) return;
15047
+ if (!store.isHydrated(modelName)) {
15048
+ Logger.warn(
15049
+ `[DocumentManager] Large document ${docId} does not hold model '${modelName}' on this device (its hydration scope excludes it), so the query tables for '${modelName}' were not hydrated. Reads of '${modelName}' on ${docId} answer as they do for any model this device declined.`
15050
+ );
15051
+ return;
15052
+ }
15053
+ await this.hydrateQueryTables(docId, store, { only: modelName });
15054
+ await this.whenProjectionSettled();
15055
+ }
14667
15056
  localModels(docId) {
14668
15057
  const store = this.ormContext.getFormat2StoreAsync(docId);
14669
15058
  if (!store) return null;
@@ -15199,12 +15588,70 @@ var DocumentManagerImpl = class {
15199
15588
  modelRegistry;
15200
15589
  /** The ORM instance this manager belongs to (#3039). */
15201
15590
  ormContext;
15591
+ /**
15592
+ * The registration gate (#3615).
15593
+ *
15594
+ * `connectDocument` finishes its model loop BEFORE it publishes the document
15595
+ * in `connectedDocuments`, so a registration that read the map in that
15596
+ * interval would miss the document while its own loop had already run —
15597
+ * leaving it connected with no table, no rows and no observer for the new
15598
+ * model. Registrations therefore run one at a time on this chain; a connect,
15599
+ * a rebind and a disconnect wait for it at entry, and a registration waits
15600
+ * for the connects that got in before it ({@link activeConnects}) before it
15601
+ * reads the map. Connects of different documents that no registration
15602
+ * overlaps still run concurrently, as they always have.
15603
+ */
15604
+ registrationChain = Promise.resolve();
15605
+ /** Connects past the gate and not yet published — what a registration waits for. */
15606
+ activeConnects = /* @__PURE__ */ new Set();
15607
+ /**
15608
+ * The `(model, document)` pairs whose initialization for this instance
15609
+ * COMPLETED (#3615).
15610
+ *
15611
+ * Registry membership alone cannot say it: a document-level failure leaves
15612
+ * the class registered but that document unusable, and `initializeForDocument`
15613
+ * clears a document's rows and installs a fresh observer on every run, so a
15614
+ * blanket retry would wipe the documents that had succeeded and double their
15615
+ * observers. A retry resumes with the pairs that are not in here.
15616
+ */
15617
+ runtimeInitialized = /* @__PURE__ */ new Map();
15618
+ /**
15619
+ * Pass the registration gate, then run `work` as an in-flight connect
15620
+ * (#3615).
15621
+ *
15622
+ * The re-read after the await is what closes the window: a registration
15623
+ * publishes its chain synchronously when it is called, so a caller that
15624
+ * waited on the chain it saw first has to check whether another one was
15625
+ * queued behind it. Joining {@link activeConnects} happens with no await in
15626
+ * between, so nothing can slip between the check and the join.
15627
+ */
15628
+ async enterConnect(work) {
15629
+ for (; ; ) {
15630
+ const chain = this.registrationChain;
15631
+ await chain;
15632
+ if (this.registrationChain === chain) break;
15633
+ }
15634
+ let settle;
15635
+ const ticket = new Promise((resolve) => settle = resolve);
15636
+ this.activeConnects.add(ticket);
15637
+ try {
15638
+ return await work();
15639
+ } finally {
15640
+ this.activeConnects.delete(ticket);
15641
+ settle();
15642
+ }
15643
+ }
15202
15644
  async connectDocument(docId, yDoc, permissionHint, options) {
15645
+ return this.enterConnect(
15646
+ () => this.connectDocumentInner(docId, yDoc, permissionHint, options)
15647
+ );
15648
+ }
15649
+ async connectDocumentInner(docId, yDoc, permissionHint, options) {
15203
15650
  if (this.connectedDocuments.has(docId)) {
15204
15651
  console.warn(
15205
15652
  `[DocumentManager] Document ${docId} is already connected. Disconnecting old version.`
15206
15653
  );
15207
- await this.disconnectDocument(docId);
15654
+ await this.disconnectDocumentInner(docId);
15208
15655
  }
15209
15656
  const documentFormat = options?.documentFormat ?? 1;
15210
15657
  const connectedDoc = {
@@ -15231,6 +15678,9 @@ var DocumentManagerImpl = class {
15231
15678
  await this.format2.connect(docId, yDoc, options?.epoch);
15232
15679
  }
15233
15680
  this.connectedDocuments.set(docId, connectedDoc);
15681
+ for (const modelName of this.modelRegistry.getActiveModels().keys()) {
15682
+ this.markRuntimeInitialized(modelName, docId);
15683
+ }
15234
15684
  this.notifyConnectionChange({
15235
15685
  type: "connect",
15236
15686
  docId,
@@ -15249,6 +15699,11 @@ var DocumentManagerImpl = class {
15249
15699
  * models read and write through is exchanged.
15250
15700
  */
15251
15701
  async rebindEpochDocument(docId, yDoc, options = {}) {
15702
+ return this.enterConnect(
15703
+ () => this.rebindEpochDocumentInner(docId, yDoc, options)
15704
+ );
15705
+ }
15706
+ async rebindEpochDocumentInner(docId, yDoc, options = {}) {
15252
15707
  const connected = this.connectedDocuments.get(docId);
15253
15708
  if (!connected) {
15254
15709
  throw new Error(
@@ -15363,6 +15818,9 @@ var DocumentManagerImpl = class {
15363
15818
  return await this.format2.recordPresence(docId, records);
15364
15819
  }
15365
15820
  async disconnectDocument(docId) {
15821
+ return this.enterConnect(() => this.disconnectDocumentInner(docId));
15822
+ }
15823
+ async disconnectDocumentInner(docId) {
15366
15824
  const connectedDoc = this.connectedDocuments.get(docId);
15367
15825
  if (!connectedDoc) {
15368
15826
  Logger.warn(`[DocumentManager] Document ${docId} is not connected`);
@@ -15379,6 +15837,9 @@ var DocumentManagerImpl = class {
15379
15837
  wipeModels ? { wipeIfLastModels: wipeModels } : {}
15380
15838
  );
15381
15839
  this.connectedDocuments.delete(docId);
15840
+ for (const docIds of this.runtimeInitialized.values()) {
15841
+ docIds.delete(docId);
15842
+ }
15382
15843
  this.ormContext.disposeObserversForDocument(docId);
15383
15844
  this.ormContext.clearMappingsForDocId(docId);
15384
15845
  this.notifyConnectionChange({
@@ -15389,10 +15850,150 @@ var DocumentManagerImpl = class {
15389
15850
  `[DocumentManager] Document ${docId} disconnected successfully`
15390
15851
  );
15391
15852
  }
15853
+ markRuntimeInitialized(modelName, docId) {
15854
+ let docIds = this.runtimeInitialized.get(modelName);
15855
+ if (!docIds) {
15856
+ docIds = /* @__PURE__ */ new Set();
15857
+ this.runtimeInitialized.set(modelName, docIds);
15858
+ }
15859
+ docIds.add(docId);
15860
+ }
15861
+ /**
15862
+ * Add a model to this running instance (#3615). See
15863
+ * {@link InitJsBaoResult.registerModel}.
15864
+ */
15865
+ async registerModel(modelClass) {
15866
+ const previous = this.registrationChain;
15867
+ let release;
15868
+ this.registrationChain = new Promise((resolve) => release = resolve);
15869
+ try {
15870
+ await previous;
15871
+ while (this.activeConnects.size > 0) {
15872
+ await Promise.all(Array.from(this.activeConnects));
15873
+ }
15874
+ await this.runRegistration(modelClass);
15875
+ } finally {
15876
+ release();
15877
+ }
15878
+ }
15879
+ async runRegistration(modelClass) {
15880
+ if (this.ormContext.destroyed) {
15881
+ throw new Error(
15882
+ `[initJsBao] Cannot register model on destroyed js-bao instance #${this.ormContext.instanceId}. Create a new instance with initJsBao.`
15883
+ );
15884
+ }
15885
+ const modelName = this.modelRegistry.assertAdoptable(
15886
+ modelClass,
15887
+ this.ormContext.instanceId
15888
+ );
15889
+ const boundTo = modelClass?._ormContext;
15890
+ if (boundTo && boundTo !== this.ormContext && !boundTo.destroyed) {
15891
+ throw new Error(
15892
+ `[initJsBao] Model '${modelName}' is bound to live js-bao instance #${boundTo.instanceId}. Give instance #${this.ormContext.instanceId} its own model class (a fresh createModelClass / attachAndRegisterModel result).`
15893
+ );
15894
+ }
15895
+ const { added } = this.modelRegistry.adoptModel(modelClass, {
15896
+ instanceId: this.ormContext.instanceId
15897
+ });
15898
+ bindModelClass(this.ormContext, modelName, modelClass, "throw");
15899
+ let didDocumentWork = false;
15900
+ for (const connected of Array.from(this.connectedDocuments.values())) {
15901
+ if (this.runtimeInitialized.get(modelName)?.has(connected.docId)) {
15902
+ continue;
15903
+ }
15904
+ didDocumentWork = true;
15905
+ try {
15906
+ await this.initializeModelForConnectedDocument(
15907
+ modelClass,
15908
+ modelName,
15909
+ connected
15910
+ );
15911
+ } catch (error) {
15912
+ await modelClass.withdrawDocumentRegistration(connected.docId);
15913
+ const message = error instanceof Error ? error.message : String(error);
15914
+ const wrapped = new Error(
15915
+ `[initJsBao] registerModel('${modelName}') failed on document ${connected.docId}; registration is incomplete and can be retried: ` + message
15916
+ );
15917
+ wrapped.cause = error;
15918
+ throw wrapped;
15919
+ }
15920
+ this.markRuntimeInitialized(modelName, connected.docId);
15921
+ }
15922
+ if (added || didDocumentWork) {
15923
+ await this.modelRegistry.initializeRelationships(this.dbEngine);
15924
+ }
15925
+ }
15926
+ /**
15927
+ * Initialize one model for one already-connected document (#3615).
15928
+ *
15929
+ * The legacy half carries the window `initializeForDocument` leaves open: it
15930
+ * loads the Y.Map as records in an asynchronous transaction and installs its
15931
+ * observers afterwards, which on a document the app connected long ago is a
15932
+ * live document still receiving peer updates. A temporary deep observer
15933
+ * collects the record ids touched while that runs; once the instance's
15934
+ * indexing for the document has drained, each of them is written from its
15935
+ * CURRENT state, so the call resolves with the query tables holding every
15936
+ * update that arrived before it did.
15937
+ */
15938
+ async initializeModelForConnectedDocument(modelClass, modelName, connected) {
15939
+ const { docId, yDoc, permissionHint } = connected;
15940
+ const documentFormat = connected.documentFormat ?? 1;
15941
+ if (documentFormat === 2) {
15942
+ await modelClass.initializeForDocument(
15943
+ yDoc,
15944
+ this.dbEngine,
15945
+ docId,
15946
+ permissionHint,
15947
+ 2
15948
+ );
15949
+ await this.format2.hydrateModel(docId, modelName);
15950
+ return;
15951
+ }
15952
+ const touched = /* @__PURE__ */ new Set();
15953
+ const documentYMap = yDoc.getMap(modelName);
15954
+ const collect2 = (events) => {
15955
+ for (const event of events) {
15956
+ if (event.target === documentYMap) {
15957
+ for (const key of event.changes.keys.keys()) touched.add(key);
15958
+ } else if (event.path.length > 0) {
15959
+ touched.add(String(event.path[0]));
15960
+ }
15961
+ }
15962
+ };
15963
+ documentYMap.observeDeep(collect2);
15964
+ try {
15965
+ await modelClass.initializeForDocument(
15966
+ yDoc,
15967
+ this.dbEngine,
15968
+ docId,
15969
+ permissionHint,
15970
+ 1
15971
+ );
15972
+ await this.whenDocumentIndexed(docId);
15973
+ for (const recordId of touched) {
15974
+ await modelClass.reindexDocumentRecord(docId, recordId);
15975
+ }
15976
+ } finally {
15977
+ documentYMap.unobserveDeep(collect2);
15978
+ }
15979
+ }
15392
15980
  /** Resolves once every enqueued query-projection refresh has been applied. */
15393
15981
  whenFormat2ProjectionSettled() {
15394
15982
  return this.format2.whenProjectionSettled();
15395
15983
  }
15984
+ /**
15985
+ * Resolves once THIS instance's query tables have caught up with `docId`
15986
+ * (#3023). Scoped to the instance's own context, so a second client holding
15987
+ * the same document id never waits on this one's writes.
15988
+ */
15989
+ async whenDocumentIndexed(docId) {
15990
+ const drained = await this.ormContext.whenDocumentIndexed(docId);
15991
+ if (!drained) {
15992
+ Logger.warn(
15993
+ `[DocumentManager] Document ${docId} is still indexing after 50 drain passes; not waiting any longer`
15994
+ );
15995
+ }
15996
+ }
15396
15997
  getConnectedDocuments() {
15397
15998
  return new Map(this.connectedDocuments);
15398
15999
  }
@@ -15420,22 +16021,33 @@ var DocumentManagerImpl = class {
15420
16021
  };
15421
16022
  function bindSessionModels(ctx, registry) {
15422
16023
  for (const [modelName, modelClass] of registry.getActiveModels()) {
15423
- const current = modelClass?._ormContext;
15424
- if (current === ctx) continue;
15425
- if (!current || current.destroyed) {
15426
- Object.defineProperty(modelClass, "_ormContext", {
15427
- value: ctx,
15428
- writable: true,
15429
- configurable: true,
15430
- enumerable: false
15431
- });
15432
- ctx.boundModels.add(modelClass);
15433
- continue;
15434
- }
15435
- console.warn(
15436
- `[initJsBao] Model '${modelName}' is already bound to a live js-bao instance #${current.instanceId} and stays with it; instance #${ctx.instanceId}'s queries, writes and subscriptions for '${modelName}' will route to instance #${current.instanceId}. Give each client its own model classes (the schemaToml path already does) if you need them isolated.`
16024
+ bindModelClass(ctx, modelName, modelClass, "warn");
16025
+ }
16026
+ }
16027
+ function bindModelClass(ctx, modelName, modelClass, onLiveConflict) {
16028
+ const current = modelClass?._ormContext;
16029
+ if (current === ctx) {
16030
+ ctx.boundModels.add(modelClass);
16031
+ return;
16032
+ }
16033
+ if (!current || current.destroyed) {
16034
+ Object.defineProperty(modelClass, "_ormContext", {
16035
+ value: ctx,
16036
+ writable: true,
16037
+ configurable: true,
16038
+ enumerable: false
16039
+ });
16040
+ ctx.boundModels.add(modelClass);
16041
+ return;
16042
+ }
16043
+ if (onLiveConflict === "throw") {
16044
+ throw new Error(
16045
+ `[initJsBao] Model '${modelName}' is bound to live js-bao instance #${current.instanceId}. Give instance #${ctx.instanceId} its own model class (a fresh createModelClass / attachAndRegisterModel result).`
15437
16046
  );
15438
16047
  }
16048
+ console.warn(
16049
+ `[initJsBao] Model '${modelName}' is already bound to a live js-bao instance #${current.instanceId} and stays with it; instance #${ctx.instanceId}'s queries, writes and subscriptions for '${modelName}' will route to instance #${current.instanceId}. Give each client its own model classes (the schemaToml path already does) if you need them isolated.`
16050
+ );
15439
16051
  }
15440
16052
  async function initJsBaoCore(options, getEngine) {
15441
16053
  const { databaseConfig, models: modelClasses } = options;
@@ -15464,6 +16076,7 @@ async function initJsBaoCore(options, getEngine) {
15464
16076
  destroy: () => ormContext.destroy(),
15465
16077
  connectDocument: docManager.connectDocument.bind(docManager),
15466
16078
  disconnectDocument: docManager.disconnectDocument.bind(docManager),
16079
+ registerModel: docManager.registerModel.bind(docManager),
15467
16080
  format2SyncMarks: (docId) => format2SyncMarks(docId, ormContext),
15468
16081
  format2StoredEpoch: (docId) => format2StoredEpoch(dbEngine, docId, ormContext),
15469
16082
  // The pre-bind epoch read opens the same pool the bind will, so it needs
@@ -15496,6 +16109,7 @@ async function initJsBaoCore(options, getEngine) {
15496
16109
  // queue updates after it). The transport awaits it before an update
15497
16110
  // from the room counts as applied.
15498
16111
  whenFormat2ProjectionSettled: docManager.whenFormat2ProjectionSettled.bind(docManager),
16112
+ whenDocumentIndexed: docManager.whenDocumentIndexed.bind(docManager),
15499
16113
  format2LoadSnapshot: docManager.loadSnapshot.bind(docManager),
15500
16114
  format2LocalModels: (docId) => docManager.localModels(docId),
15501
16115
  format2RecordPresence: (docId, records) => docManager.recordPresence(docId, records),
@@ -15899,6 +16513,26 @@ function schemaToToml(schema) {
15899
16513
 
15900
16514
  // src/models/tomlLoader.ts
15901
16515
  var import_smol_toml = require("smol-toml");
16516
+
16517
+ // src/utils/reservedFields.ts
16518
+ function checkReservedFieldName(fieldName) {
16519
+ if (fieldName === "id") return null;
16520
+ if (fieldName === "type") {
16521
+ return `Field 'type' is reserved (maps to internal _type column in queries)`;
16522
+ }
16523
+ if (fieldName.startsWith("_")) {
16524
+ return `Field '${fieldName}' is reserved (fields starting with '_' are internal)`;
16525
+ }
16526
+ return null;
16527
+ }
16528
+ var ReservedFieldNameError = class extends Error {
16529
+ constructor(message) {
16530
+ super(message);
16531
+ this.name = "ReservedFieldNameError";
16532
+ }
16533
+ };
16534
+
16535
+ // src/models/tomlLoader.ts
15902
16536
  var VALID_FIELD_TYPES = /* @__PURE__ */ new Set([
15903
16537
  "string",
15904
16538
  "number",
@@ -16045,6 +16679,7 @@ function parseRelationship(raw, context, strict) {
16045
16679
  }
16046
16680
  function loadSchemaFromTomlString(tomlString, options = {}) {
16047
16681
  const strict = options.strict !== false;
16682
+ const rejectReserved = options.rejectReservedFieldNames === true;
16048
16683
  const parsed = (0, import_smol_toml.parse)(tomlString);
16049
16684
  const models = parsed.models;
16050
16685
  if (!models || typeof models !== "object") {
@@ -16061,11 +16696,14 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
16061
16696
  const fields = {};
16062
16697
  if (modelDef.fields) {
16063
16698
  for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
16064
- fields[fieldName] = parseFieldOptions(
16065
- fieldDef,
16066
- `[models.${modelName}.fields.${fieldName}]`,
16067
- strict
16068
- );
16699
+ const context = `[models.${modelName}.fields.${fieldName}]`;
16700
+ if (rejectReserved) {
16701
+ const reserved = checkReservedFieldName(fieldName);
16702
+ if (reserved) {
16703
+ throw new ReservedFieldNameError(`${context}: ${reserved}`);
16704
+ }
16705
+ }
16706
+ fields[fieldName] = parseFieldOptions(fieldDef, context, strict);
16069
16707
  }
16070
16708
  }
16071
16709
  let relationships;
@@ -16112,6 +16750,18 @@ function loadSchemaFromTomlString(tomlString, options = {}) {
16112
16750
  }
16113
16751
  return schemas;
16114
16752
  }
16753
+ function findReservedFieldNameInToml(tomlString) {
16754
+ try {
16755
+ loadSchemaFromTomlString(tomlString, {
16756
+ strict: false,
16757
+ rejectReservedFieldNames: true
16758
+ });
16759
+ return null;
16760
+ } catch (error) {
16761
+ if (error instanceof ReservedFieldNameError) return error.message;
16762
+ return null;
16763
+ }
16764
+ }
16115
16765
 
16116
16766
  // src/browser.ts
16117
16767
  init_metaSync();