@vandenberghinc/volt 1.2.3 → 1.2.4

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.
Files changed (26) hide show
  1. package/backend/dist/cjs/backend/src/database/collection.d.ts +17 -10
  2. package/backend/dist/cjs/backend/src/database/collection.js +289 -126
  3. package/backend/dist/cjs/backend/src/payments/stripe/products.js +21 -6
  4. package/backend/dist/cjs/backend/src/stream.d.ts +58 -6
  5. package/backend/dist/cjs/backend/src/stream.js +91 -12
  6. package/backend/dist/cjs/backend/src/users.d.ts +11 -4
  7. package/backend/dist/cjs/backend/src/users.js +93 -26
  8. package/backend/dist/esm/backend/src/database/collection.d.ts +17 -10
  9. package/backend/dist/esm/backend/src/database/collection.js +311 -152
  10. package/backend/dist/esm/backend/src/database/filters/strict_update_filter_test.js +10 -0
  11. package/backend/dist/esm/backend/src/payments/stripe/products.js +24 -3
  12. package/backend/dist/esm/backend/src/stream.d.ts +58 -6
  13. package/backend/dist/esm/backend/src/stream.js +96 -12
  14. package/backend/dist/esm/backend/src/users.d.ts +11 -4
  15. package/backend/dist/esm/backend/src/users.js +95 -27
  16. package/frontend/dist/backend/src/database/collection.d.ts +17 -10
  17. package/frontend/dist/backend/src/database/collection.js +311 -152
  18. package/frontend/dist/backend/src/payments/stripe/products.js +24 -3
  19. package/frontend/dist/backend/src/stream.d.ts +58 -6
  20. package/frontend/dist/backend/src/stream.js +96 -12
  21. package/frontend/dist/backend/src/users.d.ts +11 -4
  22. package/frontend/dist/backend/src/users.js +95 -27
  23. package/frontend/dist/frontend/src/modules/user.js +3 -2
  24. package/frontend/dist/frontend/src/ui/table.d.ts +2 -2
  25. package/frontend/dist/frontend/src/ui/table.js +3 -6
  26. package/package.json +1 -1
@@ -492,6 +492,314 @@ export class Collection {
492
492
  _is_operator_update_or_pipeline(operation) {
493
493
  return Array.isArray(operation) || (operation && typeof operation === "object" && Object.keys(operation).some(k => k[0] === "$"));
494
494
  }
495
+ _index_key_signature(keys) {
496
+ // Preserve order (Mongo treats order as significant for compound indexes)
497
+ return Object.entries(keys).map(([k, v]) => `${k}:${v}`).join("|");
498
+ }
499
+ _keys_equal(a, b) {
500
+ const aEnt = Object.entries(a);
501
+ const bEnt = Object.entries(b);
502
+ if (aEnt.length !== bEnt.length)
503
+ return false;
504
+ for (let i = 0; i < aEnt.length; i++) {
505
+ const [ak, av] = aEnt[i];
506
+ const [bk, bv] = bEnt[i];
507
+ if (ak !== bk || av !== bv)
508
+ return false;
509
+ }
510
+ return true;
511
+ }
512
+ _normalize_index_opts(opts) {
513
+ // ---- Normalize inputs ----
514
+ let key;
515
+ let keys;
516
+ let options;
517
+ let unique;
518
+ let sparse;
519
+ let forced = false;
520
+ if (typeof opts === "string") {
521
+ key = opts;
522
+ unique = undefined;
523
+ sparse = undefined;
524
+ }
525
+ else {
526
+ ({ key, keys, forced = false } = opts);
527
+ const user_options = opts.options;
528
+ options = user_options ? { ...user_options } : undefined;
529
+ // Conflict guard between `unique` and `options.unique`
530
+ if (opts.unique != null && options?.unique != null && opts.unique !== options.unique) {
531
+ throw new InvalidUsageError({
532
+ message: `Encountered different values for attribute 'unique': ${opts.unique} and 'options.unique': ${options.unique}.`,
533
+ reason: "invalid_unique_option",
534
+ });
535
+ }
536
+ unique = opts.unique ?? options?.unique;
537
+ // Conflict guard between `sparse` and `options.sparse`
538
+ if (opts.sparse != null && options?.sparse != null && opts.sparse !== options.sparse) {
539
+ throw new InvalidUsageError({
540
+ message: `Encountered different values for attribute 'sparse': ${opts.sparse} and 'options.sparse': ${options.sparse}.`,
541
+ reason: "invalid_sparse_option",
542
+ });
543
+ }
544
+ sparse = opts.sparse ?? options?.sparse;
545
+ }
546
+ // Ensure `unique`/`sparse` are reflected in `options`
547
+ if (unique != null) {
548
+ options = options || {};
549
+ options.unique = unique;
550
+ }
551
+ if (sparse != null) {
552
+ options = options || {};
553
+ options.sparse = sparse;
554
+ }
555
+ // ---- Build keys object (same rules everywhere) ----
556
+ let keys_obj;
557
+ if (key) {
558
+ keys_obj = { [key]: 1 };
559
+ }
560
+ else if (Array.isArray(keys) && keys.length > 0) {
561
+ keys_obj = {};
562
+ for (const k of keys)
563
+ keys_obj[k] = 1;
564
+ }
565
+ else if (keys != null && typeof keys === "object") {
566
+ keys_obj = keys;
567
+ }
568
+ else {
569
+ throw new InvalidUsageError({
570
+ message: "Define one of the following parameters: [key, keys].",
571
+ reason: "invalid_index_definition",
572
+ });
573
+ }
574
+ return { keys_obj, options, forced };
575
+ }
576
+ /**
577
+ * Drop all indexes that are NOT part of this._init_indexes, excluding _id_ (and TTL index if enabled).
578
+ *
579
+ * @note We match by key pattern rather than name because names can differ.
580
+ */
581
+ async _drop_non_init_indexes() {
582
+ this.assert_not_transaction_based();
583
+ this.assert_init();
584
+ const existing = await this._col.listIndexes().toArray();
585
+ // Desired signatures (same parsing as create_index)
586
+ const desired = new Set();
587
+ for (const item of (this._init_indexes ?? [])) {
588
+ const { keys_obj } = this._normalize_index_opts(item);
589
+ desired.add(this._index_key_signature(keys_obj));
590
+ }
591
+ // Always keep _id_
592
+ const protected_names = new Set(["_id_"]);
593
+ // Keep TTL index if TTL is enabled (managed by _setup_ttl)
594
+ const keep_ttl = this.ttl_enabled;
595
+ const ttl_sig = this._index_key_signature({ __ttl_timestamp: 1 });
596
+ for (const ix of existing) {
597
+ const name = ix?.name;
598
+ if (!name)
599
+ continue;
600
+ if (protected_names.has(name))
601
+ continue;
602
+ const keyObj = ix.key;
603
+ if (!keyObj)
604
+ continue;
605
+ const sig = this._index_key_signature(keyObj);
606
+ if (desired.has(sig))
607
+ continue;
608
+ if (keep_ttl && sig === ttl_sig)
609
+ continue;
610
+ try {
611
+ this.db.server.log(3, `Dropping stale index "${name}" on collection: ${this.name}`);
612
+ await this._col.dropIndex(name);
613
+ }
614
+ catch (err) {
615
+ if (err?.codeName !== "IndexNotFound")
616
+ throw err;
617
+ }
618
+ }
619
+ }
620
+ /**
621
+ * Creates indexes on collections.
622
+ *
623
+ * @note When transaction mode is enabled, the session option will not be used.
624
+ *
625
+ * @param opts The index create options.
626
+ */
627
+ async _create_index(opts) {
628
+ this.assert_not_transaction_based();
629
+ if (!this.initialized) {
630
+ await this.init();
631
+ }
632
+ this.assert_init();
633
+ const { keys_obj, options, forced } = this._normalize_index_opts(opts);
634
+ const drop_index = async () => {
635
+ const existing = await this._col.listIndexes().toArray();
636
+ const match = existing.find(ix => {
637
+ const ix_key = ix?.key;
638
+ if (!ix_key)
639
+ return false;
640
+ return this._keys_equal(ix_key, keys_obj);
641
+ });
642
+ if (match?.name) {
643
+ try {
644
+ await this._col.dropIndex(match.name);
645
+ }
646
+ catch (err) {
647
+ if (err?.codeName !== "IndexNotFound")
648
+ throw err;
649
+ }
650
+ return;
651
+ }
652
+ // fallback: name provided in options
653
+ if (options?.name) {
654
+ try {
655
+ await this._col.dropIndex(options.name);
656
+ }
657
+ catch (err) {
658
+ if (err?.codeName !== "IndexNotFound")
659
+ throw err;
660
+ }
661
+ return;
662
+ }
663
+ // last resort synthesized (may not match Mongo’s generated name for compound cases)
664
+ const synthesized = Object.entries(keys_obj).map(([k, v]) => `${k}_${v}`).join("_");
665
+ try {
666
+ await this._col.dropIndex(synthesized);
667
+ }
668
+ catch (err) {
669
+ if (err?.codeName !== "IndexNotFound")
670
+ throw err;
671
+ }
672
+ };
673
+ try {
674
+ try {
675
+ return await this._col.createIndex(keys_obj, options);
676
+ }
677
+ catch (err) {
678
+ if (forced && err && typeof err === "object" && err.codeName === "IndexKeySpecsConflict") {
679
+ await drop_index();
680
+ return await this._col.createIndex(keys_obj, options);
681
+ }
682
+ throw err;
683
+ }
684
+ }
685
+ catch (err) {
686
+ throw new Error(`Failed to create index on collection "${this.name}": ${err}`, { cause: err });
687
+ }
688
+ }
689
+ // async create_index(opts: string | Collection.IndexOpts): Promise<string> {
690
+ // // Not supported on transaction-based collections.
691
+ // this.assert_not_transaction_based();
692
+ // // Ensure initialized
693
+ // if (!this.initialized) { await this.init(); } this.assert_init();
694
+ // // ---- Normalize inputs ----
695
+ // let key: string | undefined;
696
+ // let keys: string[] | Record<string, number> | undefined;
697
+ // let options: mongodb.CreateIndexesOptions | undefined;
698
+ // let unique: boolean | undefined;
699
+ // let sparse: boolean | undefined;
700
+ // let forced = false;
701
+ // if (typeof opts === "string") {
702
+ // key = opts;
703
+ // unique = undefined;
704
+ // sparse = undefined;
705
+ // } else {
706
+ // ({ key, keys, forced = false } = opts);
707
+ // const options = opts.options as unknown as undefined | mongodb.CreateIndexesOptions;
708
+ // // Conflict guard between `unique` and `options.unique`
709
+ // if (opts.unique != null && options?.unique != null && opts.unique !== options.unique) {
710
+ // throw new InvalidUsageError({
711
+ // message: `Encountered different values for attribute 'unique': ${opts.unique} and 'options.unique': ${options.unique}.`,
712
+ // reason: "invalid_unique_option",
713
+ // });
714
+ // }
715
+ // unique = opts.unique ?? options?.unique;
716
+ // // Conflict guard between `sparse` and `options.sparse`
717
+ // if (opts.sparse != null && options?.sparse != null && opts.sparse !== options.sparse) {
718
+ // throw new InvalidUsageError({
719
+ // message: `Encountered different values for attribute 'sparse': ${opts.sparse} and 'options.sparse': ${options.sparse}.`,
720
+ // reason: "invalid_sparse_option",
721
+ // });
722
+ // }
723
+ // sparse = opts.sparse ?? options?.sparse;
724
+ // }
725
+ // // Ensure `unique` in options when provided
726
+ // if (unique) {
727
+ // options = options || {};
728
+ // options.unique = unique;
729
+ // }
730
+ // // Ensure `sparse` in options when provided
731
+ // if (sparse) {
732
+ // options = options || {};
733
+ // options.sparse = sparse;
734
+ // }
735
+ // // Build keys object
736
+ // let keys_obj: Record<string, number>;
737
+ // if (key) {
738
+ // keys_obj = { [key]: 1 };
739
+ // } else if (Array.isArray(keys) && keys.length > 0) {
740
+ // keys_obj = {};
741
+ // for (const k of keys) keys_obj[k] = 1;
742
+ // } else if (keys != null && typeof keys === "object") {
743
+ // keys_obj = keys as Record<string, number>;
744
+ // } else {
745
+ // throw new InvalidUsageError({
746
+ // message: "Define one of the following parameters: [key, keys].",
747
+ // reason: "invalid_index_definition",
748
+ // });
749
+ // }
750
+ // const drop_index = async () => {
751
+ // try {
752
+ // const existing = await this._col.listIndexes().toArray();
753
+ // const match = existing.find(ix => {
754
+ // const ix_key = ix?.key as Record<string, number> | undefined;
755
+ // if (!ix_key) return false;
756
+ // const a = Object.entries(ix_key);
757
+ // const b = Object.entries(keys_obj);
758
+ // if (a.length !== b.length) return false;
759
+ // // exact key-value equality (order-insensitive)
760
+ // const as = new Map(a);
761
+ // for (const [kk, vv] of b) {
762
+ // if (as.get(kk) !== vv) return false;
763
+ // }
764
+ // return true;
765
+ // });
766
+ // // Prefer matched key's real name
767
+ // if (match?.name) {
768
+ // try { await this._col.dropIndex(match.name); }
769
+ // catch (err: any) { if (err?.codeName !== "IndexNotFound") throw err; }
770
+ // } else if (options?.name) {
771
+ // try { await this._col.dropIndex(options.name); }
772
+ // catch (err: any) { if (err?.codeName !== "IndexNotFound") throw err; }
773
+ // } else {
774
+ // // last-resort synthesized name (simple cases)
775
+ // const synthesized = Object.entries(keys_obj).map(([k, v]) => `${k}_${v}`).join("_");
776
+ // try { await this._col.dropIndex(synthesized); }
777
+ // catch (err: any) { if (err?.codeName !== "IndexNotFound") throw err; }
778
+ // }
779
+ // } catch (err) {
780
+ // // If listIndexes itself fails for some reason, do not hide the error
781
+ // throw new Error(`Failed to create index on collection "${this.name}": ${err}`, { cause: err });
782
+ // }
783
+ // }
784
+ // try {
785
+ // // Create (or re-create)
786
+ // try {
787
+ // return await this._col.createIndex(keys_obj, options);
788
+ // }
789
+ // // Retry once on IndexKeySpecsConflict when forced=true
790
+ // catch (err) {
791
+ // if (forced && err && typeof err === "object" && (
792
+ // (err as any).codeName === "IndexKeySpecsConflict"
793
+ // )) {
794
+ // await drop_index();
795
+ // return await this._col.createIndex(keys_obj, options);
796
+ // }
797
+ // throw err;
798
+ // }
799
+ // } catch (err) {
800
+ // throw new Error(`Failed to create index on collection "${this.name}": ${err}`, { cause: err });
801
+ // }
802
+ // }
495
803
  // -------------------------------------------------------------------
496
804
  // Public methods.
497
805
  // -------------------------------------------------------------------
@@ -567,11 +875,13 @@ export class Collection {
567
875
  this.db.server.log(3, "Setting up TTL index for collection: " + this.name);
568
876
  await this._setup_ttl();
569
877
  }
878
+ // Drop indexes that are not in this._init_indexes (keep _id_ + TTL).
879
+ await this._drop_non_init_indexes();
570
880
  // Create indexes.
571
881
  if (this._init_indexes?.length) {
572
882
  for (const item of this._init_indexes) {
573
883
  this.db.server.log(3, "Creating index " + JSON.stringify(item) + " on collection: " + this.name);
574
- await this.create_index(item);
884
+ await this._create_index(item);
575
885
  }
576
886
  }
577
887
  }
@@ -686,157 +996,6 @@ export class Collection {
686
996
  // No need to pass session obj here.
687
997
  return (await this._col.listIndexes().toArray()).some(x => x.name === index);
688
998
  }
689
- /**
690
- * Creates indexes on collections.
691
- *
692
- * @note When transaction mode is enabled, the session option will not be used.
693
- *
694
- * @param opts The index create options.
695
- *
696
- * @docs
697
- */
698
- async create_index(opts) {
699
- // Not supported on transaction-based collections.
700
- this.assert_not_transaction_based();
701
- // Ensure initialized
702
- if (!this.initialized) {
703
- await this.init();
704
- }
705
- this.assert_init();
706
- // ---- Normalize inputs ----
707
- let key;
708
- let keys;
709
- let options;
710
- let unique;
711
- let sparse;
712
- let forced = false;
713
- if (typeof opts === "string") {
714
- key = opts;
715
- unique = undefined;
716
- sparse = undefined;
717
- }
718
- else {
719
- ({ key, keys, forced = false } = opts);
720
- const options = opts.options;
721
- // Conflict guard between `unique` and `options.unique`
722
- if (opts.unique != null && options?.unique != null && opts.unique !== options.unique) {
723
- throw new InvalidUsageError({
724
- message: `Encountered different values for attribute 'unique': ${opts.unique} and 'options.unique': ${options.unique}.`,
725
- reason: "invalid_unique_option",
726
- });
727
- }
728
- unique = opts.unique ?? options?.unique;
729
- // Conflict guard between `sparse` and `options.sparse`
730
- if (opts.sparse != null && options?.sparse != null && opts.sparse !== options.sparse) {
731
- throw new InvalidUsageError({
732
- message: `Encountered different values for attribute 'sparse': ${opts.sparse} and 'options.sparse': ${options.sparse}.`,
733
- reason: "invalid_sparse_option",
734
- });
735
- }
736
- sparse = opts.sparse ?? options?.sparse;
737
- }
738
- // Ensure `unique` in options when provided
739
- if (unique) {
740
- options = options || {};
741
- options.unique = unique;
742
- }
743
- // Ensure `sparse` in options when provided
744
- if (sparse) {
745
- options = options || {};
746
- options.sparse = sparse;
747
- }
748
- // Build keys object
749
- let keys_obj;
750
- if (key) {
751
- keys_obj = { [key]: 1 };
752
- }
753
- else if (Array.isArray(keys) && keys.length > 0) {
754
- keys_obj = {};
755
- for (const k of keys)
756
- keys_obj[k] = 1;
757
- }
758
- else if (keys != null && typeof keys === "object") {
759
- keys_obj = keys;
760
- }
761
- else {
762
- throw new InvalidUsageError({
763
- message: "Define one of the following parameters: [key, keys].",
764
- reason: "invalid_index_definition",
765
- });
766
- }
767
- const drop_index = async () => {
768
- try {
769
- const existing = await this._col.listIndexes().toArray();
770
- const match = existing.find(ix => {
771
- const ix_key = ix?.key;
772
- if (!ix_key)
773
- return false;
774
- const a = Object.entries(ix_key);
775
- const b = Object.entries(keys_obj);
776
- if (a.length !== b.length)
777
- return false;
778
- // exact key-value equality (order-insensitive)
779
- const as = new Map(a);
780
- for (const [kk, vv] of b) {
781
- if (as.get(kk) !== vv)
782
- return false;
783
- }
784
- return true;
785
- });
786
- // Prefer matched key's real name
787
- if (match?.name) {
788
- try {
789
- await this._col.dropIndex(match.name);
790
- }
791
- catch (err) {
792
- if (err?.codeName !== "IndexNotFound")
793
- throw err;
794
- }
795
- }
796
- else if (options?.name) {
797
- try {
798
- await this._col.dropIndex(options.name);
799
- }
800
- catch (err) {
801
- if (err?.codeName !== "IndexNotFound")
802
- throw err;
803
- }
804
- }
805
- else {
806
- // last-resort synthesized name (simple cases)
807
- const synthesized = Object.entries(keys_obj).map(([k, v]) => `${k}_${v}`).join("_");
808
- try {
809
- await this._col.dropIndex(synthesized);
810
- }
811
- catch (err) {
812
- if (err?.codeName !== "IndexNotFound")
813
- throw err;
814
- }
815
- }
816
- }
817
- catch (err) {
818
- // If listIndexes itself fails for some reason, do not hide the error
819
- throw new Error(`Failed to create index on collection "${this.name}": ${err}`, { cause: err });
820
- }
821
- };
822
- try {
823
- // Create (or re-create)
824
- try {
825
- return await this._col.createIndex(keys_obj, options);
826
- }
827
- // Retry once on IndexKeySpecsConflict when forced=true
828
- catch (err) {
829
- if (forced && err && typeof err === "object" && (err.codeName === "IndexKeySpecsConflict")) {
830
- await drop_index();
831
- return await this._col.createIndex(keys_obj, options);
832
- }
833
- throw err;
834
- }
835
- }
836
- catch (err) {
837
- throw new Error(`Failed to create index on collection "${this.name}": ${err}`, { cause: err });
838
- }
839
- }
840
999
  /**
841
1000
  * Standalone helper: merge `source` into `target` for missing keys only.
842
1001
  * Clones assigned nested objects/arrays/dates once (when `clone` is true).
@@ -224,6 +224,7 @@ async function list_all_stripe_products(client) {
224
224
  const page = await stripe_api_call(() => client.products.list({
225
225
  limit: stripe_list_page_size,
226
226
  starting_after,
227
+ expand: ["data.default_price"],
227
228
  }), { operation: "products.list_all", starting_after });
228
229
  all_products.push(...page.data);
229
230
  if (!page.has_more || page.data.length === 0) {
@@ -408,6 +409,7 @@ async function create_stripe_product(client, server, product) {
408
409
  metadata: {
409
410
  [app_product_id_metadata_key]: product.id,
410
411
  },
412
+ expand: ["default_price"],
411
413
  }, { idempotencyKey: generate_random_idempotency_key(`create_product_${product.id}`) }), { operation: "products.create", app_product_id: product.id });
412
414
  }
413
415
  /**
@@ -433,13 +435,32 @@ async function update_stripe_product_if_needed(client, server, stripe_product, p
433
435
  description: product.description,
434
436
  tax_code: product.tax_code,
435
437
  images: product.images,
438
+ expand: ["default_price"],
436
439
  }, { idempotencyKey: generate_random_idempotency_key(`update_product_${product.id}_${stripe_product.id}`) }), { operation: "products.update", app_product_id: product.id, stripe_product_id: stripe_product.id });
437
440
  }
438
441
  /**
439
442
  * Update default price on a stripe product if needed.
440
443
  */
441
- async function update_stripe_product_default_price_if_needed(client, server, stripe_product, default_price) {
442
- if (stripe_product.default_price === default_price.id) {
444
+ async function update_stripe_product_default_price_if_needed(client, server, stripe_product, default_price, other_plans_from_parent_subscription) {
445
+ // Extract default price.
446
+ let default_price_id = null;
447
+ if (typeof stripe_product.default_price === "string") {
448
+ default_price_id = stripe_product.default_price;
449
+ }
450
+ else if (stripe_product.default_price && typeof stripe_product.default_price === "object") {
451
+ default_price_id = stripe_product.default_price.id;
452
+ }
453
+ // If its still undefined, fetch the product to get the default_price expanded (this should be rare since we expand it on list/create/update).
454
+ if (!default_price_id) {
455
+ const fetched = await stripe_api_call(() => client.products.retrieve(stripe_product.id, {
456
+ expand: ["default_price"],
457
+ }), { operation: "products.retrieve_for_default_price", stripe_product_id: stripe_product.id });
458
+ default_price_id = typeof fetched.default_price === "string" ? fetched.default_price : fetched.default_price?.id ?? null;
459
+ }
460
+ // If the default price is already correct, do nothing.
461
+ if (default_price_id === default_price.id
462
+ // For subscription products, the default_price may be shared across multiple plans, so we also check if any other plan from the same subscription is using the price. If so, we should not update the default_price since it would affect those plans as well.
463
+ || other_plans_from_parent_subscription?.some((plan) => plan.stripe_price_id === default_price_id)) {
443
464
  return;
444
465
  }
445
466
  server.log(0, `Updating default price for Stripe product '${stripe_product.id}' to price '${default_price.id}'`);
@@ -756,7 +777,7 @@ async function initialize_product(client, server, product, stripe_products_by_ap
756
777
  const updated_prices = [...active_prices, stripe_price];
757
778
  active_prices_by_stripe_product_id.set(stripe_product.id, updated_prices);
758
779
  }
759
- await update_stripe_product_default_price_if_needed(client, server, stripe_product, stripe_price);
780
+ await update_stripe_product_default_price_if_needed(client, server, stripe_product, stripe_price, initialized_plans);
760
781
  initialized_plans.push({
761
782
  ...plan,
762
783
  type: "subscription_plan",
@@ -362,17 +362,69 @@ export declare class Stream {
362
362
  */
363
363
  remove_headers(...names: string[]): this;
364
364
  /**
365
- * Set a cookie that will be sent with the response.
365
+ * Set a cookie to be sent with the response.
366
+ *
367
+ * Accepts either:
368
+ * 1) a pre-built cookie header string (used as-is, no validation), or
369
+ * 2) a structured object describing the cookie, from which a standards-compliant
370
+ * cookie string will be generated.
371
+ *
372
+ * If a cookie with the same name already exists in the pending response list,
373
+ * it will be replaced.
374
+ *
375
+ * @warning Cookies are only included in the response when using `send()`,
376
+ * `success()` or `error()`.
366
377
  *
367
- * @warning Will only be added to the response when the user uses `send()`, `success()` or `error()`.
368
- * @param cookie The cookie string.
369
378
  * @example
370
379
  * ```ts
371
- * stream.set_cookie("MyCookie=Hello World;");
380
+ * stream.set_cookie("sid=abc123; Path=/; SameSite=Lax; Secure; HttpOnly");
381
+ *
382
+ * stream.set_cookie({
383
+ * name: "sid",
384
+ * value: session_id,
385
+ * http_only: true,
386
+ * secure: true,
387
+ * same_site: "Lax",
388
+ * path: "/",
389
+ * max_age: 60 * 60 * 24 * 14,
390
+ * });
372
391
  * ```
373
- * @docs
374
392
  */
375
- set_cookie(cookie: string): this;
393
+ set_cookie(cookie: string | {
394
+ /** Cookie name (required). */
395
+ name: string;
396
+ /** Cookie value. Will be URI-encoded. Defaults to empty string. */
397
+ value?: string | number | boolean | null;
398
+ /** Cookie path attribute. Defaults to "/". */
399
+ path?: string;
400
+ /** Cookie domain attribute. */
401
+ domain?: string;
402
+ /** Max-Age in seconds. Must be a finite number. */
403
+ max_age?: number;
404
+ /** Expiration date (Date or preformatted HTTP date string). */
405
+ expires?: Date | string;
406
+ /** Adds the Secure attribute (HTTPS only). */
407
+ secure?: boolean;
408
+ /** Adds the HttpOnly attribute (not accessible to JS). */
409
+ http_only?: boolean;
410
+ /**
411
+ * SameSite attribute.
412
+ * Use "Lax" for most session cookies.
413
+ * Use "None" only together with `secure: true`.
414
+ */
415
+ same_site?: "Strict" | "Lax" | "None";
416
+ /**
417
+ * Cookie name prefix.
418
+ * "__Host-" requires: secure=true, path="/", and no domain.
419
+ * "__Secure-" requires: secure=true.
420
+ */
421
+ prefix?: "__Host-" | "__Secure-";
422
+ /**
423
+ * Additional raw attributes appended verbatim.
424
+ * Example: ["Priority=High"]
425
+ */
426
+ extra?: string[];
427
+ }): this;
376
428
  /**
377
429
  * Set cookies that will be sent with the response.
378
430
  *